From dcf6935fb93e148d300e6c15dced381f67118e5a Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 21 Jul 2026 10:31:28 +0200 Subject: [PATCH 01/16] platform-lite: serve the ClickHouse logs endpoint (/analytics/endpoints/logs) mcp >= the #326 migration queries /v1/projects/{ref}/analytics/endpoints/logs with ClickHouse-dialect SQL over a unified 'logs' stream; platform-lite only served the legacy BigQuery-era logs.all, so any logs eval against a current mcp build 404s at the fixture. - unified 'logs' VIEW over the seeded tables (source discriminator + log_attributes jsonb built from columns, metadata fallback) - minimal dialect translation: log_attributes['k'] -> jsonb access (numeric cast for status/exec-time keys), countIf -> count(*) FILTER - read-only enforced by a postgres read-only transaction (not regex): mutating SQL incl. data-modifying CTEs is rejected before touching fixture state - iso_timestamp_start/end accepted but ignored (scenario seeds carry fixed dates; the legacy route makes the same choice) - contract test: mcp edge-function preset, countIf aggregation, runtime source --- .../src/management-api/debugging.test.ts | 79 +++++++++++++++++++ .../src/management-api/debugging.ts | 58 ++++++++++++++ .../platform-lite/src/project/log-seeding.ts | 29 +++++++ 3 files changed, 166 insertions(+) create mode 100644 packages/platform-lite/src/management-api/debugging.test.ts diff --git a/packages/platform-lite/src/management-api/debugging.test.ts b/packages/platform-lite/src/management-api/debugging.test.ts new file mode 100644 index 00000000..da5808fa --- /dev/null +++ b/packages/platform-lite/src/management-api/debugging.test.ts @@ -0,0 +1,79 @@ +import { PGlite } from '@electric-sql/pglite' +import { afterAll, describe, expect, it } from 'vitest' + +import { LOGS_BASE_SQL, seedLogRow } from '../project/log-seeding.js' +import { compileClickHouseLogsSql } from './debugging.js' + +// Contract test for the ClickHouse-shaped /analytics/endpoints/logs fixture: +// the SQL current mcp emits (get_logs presets and query_logs-style aggregation +// over the unified 'logs' stream) must run against the local logsDb. + +const logsDb = new PGlite() +await logsDb.exec(LOGS_BASE_SQL) +for (const [id, functionId, status] of [ + ['t1', 'stripe-webhook', 500], + ['t2', 'stripe-webhook', 500], + ['t3', 'stripe-webhook', 200], + ['t4', 'send-email', 500], + ['t5', 'send-email', 200], +] as const) { + await seedLogRow(logsDb, { + id, + ts: new Date('2026-04-28T10:00:00Z'), + source: 'edge-function', + level: status >= 500 ? 'error' : 'info', + message: 'request completed', + metadata: { function_id: functionId, status, duration_ms: 100 }, + }) +} +afterAll(() => logsDb.close()) + +// verbatim from mcp getClickHouseLogQuery('edge-function') +const EDGE_FUNCTION_PRESET = `select id, timestamp, event_message, log_attributes['response.status_code'] as status_code, log_attributes['request.method'] as method, log_attributes['function_id'] as function_id, log_attributes['execution_time_ms'] as execution_time_ms, log_attributes['deployment_id'] as deployment_id, log_attributes['version'] as version +from logs +where source = 'function_edge_logs' +order by timestamp desc +limit 100` + +describe('compileClickHouseLogsSql + unified logs view', () => { + it('runs the mcp edge-function preset', async () => { + const result = await logsDb.query<{ function_id: string; status_code: unknown }>( + compileClickHouseLogsSql(EDGE_FUNCTION_PRESET) + ) + expect(result.rows).toHaveLength(5) + expect(result.rows.map((r) => r.function_id).sort()).toEqual([ + 'send-email', + 'send-email', + 'stripe-webhook', + 'stripe-webhook', + 'stripe-webhook', + ]) + }) + + it('runs a query_logs-style countIf aggregation (top error function)', async () => { + const sql = `select log_attributes['function_id'] as function_id, + countIf(log_attributes['response.status_code'] >= 500) as error_count, + count(*) as total_count + from logs + where source = 'function_edge_logs' + group by log_attributes['function_id'] + order by error_count desc + limit 5` + const result = await logsDb.query<{ function_id: string; error_count: string | number; total_count: string | number }>( + compileClickHouseLogsSql(sql) + ) + expect(result.rows[0]).toMatchObject({ function_id: 'stripe-webhook' }) + expect(Number(result.rows[0]!.error_count)).toBe(2) + expect(Number(result.rows[0]!.total_count)).toBe(3) + expect(Number(result.rows[1]!.error_count)).toBe(1) + }) + + it('runs the runtime (function_logs) preset source', async () => { + const result = await logsDb.query( + compileClickHouseLogsSql( + `select id, timestamp, event_message, severity_text, log_attributes['level'] as level, log_attributes['function_id'] as function_id from logs where source = 'function_logs' order by timestamp desc limit 10` + ) + ) + expect(result.rows.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/platform-lite/src/management-api/debugging.ts b/packages/platform-lite/src/management-api/debugging.ts index 96fe86d6..69591879 100644 --- a/packages/platform-lite/src/management-api/debugging.ts +++ b/packages/platform-lite/src/management-api/debugging.ts @@ -34,6 +34,44 @@ export function createDebuggingRoutes(store: ProjectStore): ManagementApiRoutes } }) + // Current mcp (>= the #326 ClickHouse migration): GET /analytics/endpoints/logs + // with ClickHouse-dialect sql over the unified 'logs' stream. The 'logs' VIEW + // (log-seeding.ts) provides the shape; only map access and countIf need + // translating. iso_timestamp_start/end are accepted but IGNORED on purpose: + // scenario seeds carry fixed dates, while mcp defaults the window from the + // current clock — a faithful filter would empty every scenario (the legacy + // logs.all route makes the same choice). + routes.get('/v1/projects/:ref/analytics/endpoints/logs', async (c) => { + const { ref } = c.req.param() + const project = store.get(ref) + if (!project) return c.json({ message: 'Project not found' }, 404) + + const sql = + c.req.query('sql') ?? + "select id, timestamp, event_message from logs where source = 'edge_logs' order by timestamp desc limit 100" + + // The hosted endpoint is read-only server-side; enforce the same contract on + // model-authored SQL. The prefix check only shapes the error message — the + // REAL enforcement is the read-only transaction below, which postgres applies + // to every statement including data-modifying CTEs. + const stmt = sql.trim().replace(/;+\s*$/, '') + if (stmt.includes(';') || !/^\s*(select|with)\b/i.test(stmt)) { + return c.json({ result: [], error: 'only a single read-only SELECT statement is supported' }, 400) + } + + try { + const compiled = compileClickHouseLogsSql(stmt) + const result = await project.logsDb.transaction(async (tx) => { + await tx.exec('SET TRANSACTION READ ONLY') + return tx.query(compiled) + }) + return c.json({ result: (result as { rows: unknown[] }).rows }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return c.json({ result: [], error: message }) + } + }) + routes.get('/v1/projects/:ref/advisors/security', async (c) => { const { ref } = c.req.param() const project = store.get(ref) @@ -72,6 +110,26 @@ export function createDebuggingRoutes(store: ProjectStore): ManagementApiRoutes return routes } +/** + * Translate ClickHouse-dialect SQL (as current mcp emits for the unified logs + * stream) into PGlite SQL against the 'logs' VIEW. Two constructs need help: + * log_attributes['k'] -> (log_attributes->>'k') (numeric keys get a cast + * so agent-written comparisons like >= 500 work) + * countIf(cond) -> count(*) FILTER (WHERE cond) + * Everything else (select/where/group/order/limit over the view) is plain SQL. + */ +const NUMERIC_LOG_ATTRIBUTES = new Set(['response.status_code', 'status_code', 'execution_time_ms']) + +export function compileClickHouseLogsSql(sql: string): string { + return sql + .replace(/\blog_attributes\['([^']+)'\]/gi, (_m, key: string) => + NUMERIC_LOG_ATTRIBUTES.has(key) + ? `((log_attributes->>'${key}')::numeric)` + : `(log_attributes->>'${key}')` + ) + .replace(/\bcountIf\s*\(/gi, 'count(*) FILTER (WHERE ') +} + function compileLogsSql(sql: string): string { let compiled = sql.trim() diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index c7e87475..7649e159 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -90,6 +90,35 @@ CREATE TABLE IF NOT EXISTS storage_logs ( level text, metadata jsonb NOT NULL DEFAULT '{}'::jsonb ); + +-- Unified ClickHouse-shaped stream: the hosted /analytics/endpoints/logs +-- endpoint exposes one 'logs' relation with a 'source' discriminator and a +-- log_attributes map. Mirror it so ClickHouse-dialect SQL from current mcp +-- (get_logs presets, query_logs) runs with minimal translation. Column-backed +-- attributes win over seeded metadata; nulls fall back to metadata keys. +CREATE VIEW logs AS + SELECT id, identifier, timestamp, ts, event_message, message, level, level AS severity_text, 'edge_logs'::text AS source, + metadata || jsonb_strip_nulls(jsonb_build_object('identifier', identifier, 'request.method', method, 'request.path', path, 'response.status_code', status_code)) AS log_attributes + FROM edge_logs + UNION ALL + SELECT id, identifier, timestamp, ts, event_message, message, level, level, 'function_edge_logs', + metadata || jsonb_strip_nulls(jsonb_build_object('response.status_code', status_code, 'request.method', method, 'function_id', function_id, 'execution_time_ms', execution_time_ms, 'deployment_id', deployment_id, 'version', version)) + FROM function_edge_logs + UNION ALL + SELECT id, identifier, timestamp, ts, event_message, message, level, level, 'function_logs', + metadata || jsonb_strip_nulls(jsonb_build_object('level', level, 'function_id', function_id, 'deployment_id', deployment_id, 'version', version)) + FROM function_edge_logs + UNION ALL + SELECT id, identifier, timestamp, ts, event_message, message, level, level, 'postgres_logs', + metadata || jsonb_strip_nulls(jsonb_build_object('identifier', identifier, 'parsed.error_severity', error_severity)) + FROM postgres_logs + UNION ALL + SELECT id, identifier, timestamp, ts, event_message, message, level, level, 'auth_logs', + metadata || jsonb_strip_nulls(jsonb_build_object('level', level, 'status', status, 'path', path, 'msg', msg, 'error', error)) + FROM auth_logs + UNION ALL + SELECT id, identifier, timestamp, ts, event_message, message, level, level, 'storage_logs', metadata + FROM storage_logs; ` export async function seedLogRow(logsDb: PGlite, row: LogRow): Promise { From 15af251b20e950dbfcdbcc2117b5fa64bee24846 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 21 Jul 2026 10:40:28 +0200 Subject: [PATCH 02/16] platform-lite: ClickHouse toIntOrZero family for query_logs SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live A/B of mcp PR#333 showed claude-sonnet-5 emitting genuine ClickHouse (countIf(toInt32OrZero(log_attributes['status']) >= 400)); the fixture rejected it and the model adapted with postgres-only SQL that the hosted ClickHouse endpoint would refuse — greening the eval by fixture-adaptation. Provide toInt32OrZero/toInt64OrZero/toUInt32OrZero (text + numeric overloads, CH 0-on-garbage semantics) so the fixture accepts the model's natural dialect. Contract test uses the verbatim model-emitted query. --- .../src/management-api/debugging.test.ts | 15 +++++++++++++++ .../platform-lite/src/project/log-seeding.ts | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/platform-lite/src/management-api/debugging.test.ts b/packages/platform-lite/src/management-api/debugging.test.ts index da5808fa..d801fc79 100644 --- a/packages/platform-lite/src/management-api/debugging.test.ts +++ b/packages/platform-lite/src/management-api/debugging.test.ts @@ -76,4 +76,19 @@ describe('compileClickHouseLogsSql + unified logs view', () => { ) expect(result.rows.length).toBeGreaterThan(0) }) + it('runs the exact ClickHouse SQL claude-sonnet-5 emitted in the PR-333 A/B (toInt32OrZero)', async () => { + // verbatim model output from results-ab/investigate-logs-001-top-error-function.treatment.json + const sql = `select log_attributes['function_id'] as function_id, + count(*) as total_events, + countIf(level = 'error' or toInt32OrZero(log_attributes['status']) >= 400) as error_count +from logs +where source = 'function_edge_logs' +group by function_id +order by error_count desc` + const result = await logsDb.query<{ function_id: string; error_count: string | number; total_count: unknown }>( + compileClickHouseLogsSql(sql) + ) + expect(result.rows[0]).toMatchObject({ function_id: 'stripe-webhook' }) + expect(Number((result.rows[0] as { error_count: unknown }).error_count)).toBe(2) + }) }) diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index 7649e159..d8605ef4 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -119,6 +119,23 @@ CREATE VIEW logs AS UNION ALL SELECT id, identifier, timestamp, ts, event_message, message, level, level, 'storage_logs', metadata FROM storage_logs; + +-- ClickHouse numeric-cast family, as models genuinely emit it in query_logs +-- SQL (e.g. countIf(toInt32OrZero(log_attributes['status']) >= 400)). +-- ClickHouse semantics: parse the value, 0 when it isn't a number. Text and +-- numeric overloads cover both raw jsonb access and the translator's casts. +CREATE FUNCTION toInt32OrZero(v text) RETURNS numeric AS $ch$ +BEGIN RETURN coalesce(v::numeric, 0); EXCEPTION WHEN others THEN RETURN 0; END +$ch$ LANGUAGE plpgsql IMMUTABLE; +CREATE FUNCTION toInt32OrZero(v numeric) RETURNS numeric AS $ch$ SELECT coalesce(v, 0) $ch$ LANGUAGE sql IMMUTABLE; +CREATE FUNCTION toInt64OrZero(v text) RETURNS numeric AS $ch$ +BEGIN RETURN coalesce(v::numeric, 0); EXCEPTION WHEN others THEN RETURN 0; END +$ch$ LANGUAGE plpgsql IMMUTABLE; +CREATE FUNCTION toInt64OrZero(v numeric) RETURNS numeric AS $ch$ SELECT coalesce(v, 0) $ch$ LANGUAGE sql IMMUTABLE; +CREATE FUNCTION toUInt32OrZero(v text) RETURNS numeric AS $ch$ +BEGIN RETURN coalesce(v::numeric, 0); EXCEPTION WHEN others THEN RETURN 0; END +$ch$ LANGUAGE plpgsql IMMUTABLE; +CREATE FUNCTION toUInt32OrZero(v numeric) RETURNS numeric AS $ch$ SELECT coalesce(v, 0) $ch$ LANGUAGE sql IMMUTABLE; ` export async function seedLogRow(logsDb: PGlite, row: LogRow): Promise { From c60adebfd5d4446069bb322989be83e1a177c9de Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 21 Jul 2026 10:44:13 +0200 Subject: [PATCH 03/16] platform-lite: polymorphic toString for ClickHouse-dialect logs SQL Second fixture gap from the live PR#333 treatment rerun: the model nests toString() inside toInt32OrZero(). One anyelement cast function covers it; verbatim-model-SQL contract test added. --- .../platform-lite/src/management-api/debugging.test.ts | 8 ++++++++ packages/platform-lite/src/project/log-seeding.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/packages/platform-lite/src/management-api/debugging.test.ts b/packages/platform-lite/src/management-api/debugging.test.ts index d801fc79..fcf2bc8c 100644 --- a/packages/platform-lite/src/management-api/debugging.test.ts +++ b/packages/platform-lite/src/management-api/debugging.test.ts @@ -91,4 +91,12 @@ order by error_count desc` expect(result.rows[0]).toMatchObject({ function_id: 'stripe-webhook' }) expect(Number((result.rows[0] as { error_count: unknown }).error_count)).toBe(2) }) + + it('runs the toString-nested ClickHouse SQL from the treatment rerun', async () => { + // verbatim model output (rerun call 4): toString wrapped inside toInt32OrZero + const sql = `select log_attributes['function_id'] as function_id, count(*) as total_events, countIf(toInt32OrZero(toString(log_attributes['status'])) >= 400 or level = 'error') as error_count from logs where source = 'function_edge_logs' group by function_id order by error_count desc` + const result = await logsDb.query<{ function_id: string; error_count: unknown }>(compileClickHouseLogsSql(sql)) + expect(result.rows[0]).toMatchObject({ function_id: 'stripe-webhook' }) + expect(Number((result.rows[0] as { error_count: unknown }).error_count)).toBe(2) + }) }) diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index d8605ef4..58cabe4e 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -136,6 +136,7 @@ CREATE FUNCTION toUInt32OrZero(v text) RETURNS numeric AS $ch$ BEGIN RETURN coalesce(v::numeric, 0); EXCEPTION WHEN others THEN RETURN 0; END $ch$ LANGUAGE plpgsql IMMUTABLE; CREATE FUNCTION toUInt32OrZero(v numeric) RETURNS numeric AS $ch$ SELECT coalesce(v, 0) $ch$ LANGUAGE sql IMMUTABLE; +CREATE FUNCTION toString(v anyelement) RETURNS text AS $ch$ SELECT v::text $ch$ LANGUAGE sql IMMUTABLE; ` export async function seedLogRow(logsDb: PGlite, row: LogRow): Promise { From 2e3d2b3a546a52770394d0bb826b3bd69af110d1 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 21 Jul 2026 10:47:52 +0200 Subject: [PATCH 04/16] platform-lite: document the deliberate ClickHouse-compat surface + time-semantics limitation --- .../platform-lite/src/management-api/debugging.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/platform-lite/src/management-api/debugging.ts b/packages/platform-lite/src/management-api/debugging.ts index 69591879..8fb85c60 100644 --- a/packages/platform-lite/src/management-api/debugging.ts +++ b/packages/platform-lite/src/management-api/debugging.ts @@ -112,11 +112,21 @@ export function createDebuggingRoutes(store: ProjectStore): ManagementApiRoutes /** * Translate ClickHouse-dialect SQL (as current mcp emits for the unified logs - * stream) into PGlite SQL against the 'logs' VIEW. Two constructs need help: + * stream) into PGlite SQL against the 'logs' VIEW. The supported surface is + * DELIBERATELY partial — exactly what models have been observed to emit: * log_attributes['k'] -> (log_attributes->>'k') (numeric keys get a cast * so agent-written comparisons like >= 500 work) * countIf(cond) -> count(*) FILTER (WHERE cond) - * Everything else (select/where/group/order/limit over the view) is plain SQL. + * toInt32OrZero/toInt64OrZero/toUInt32OrZero/toString -> SQL shims in + * LOGS_BASE_SQL (log-seeding.ts) + * Anything else surfaces the raw SQL error to the model, which adapts — fine + * for exploration, but remember: a query that only works here (postgres-isms) + * would FAIL against the hosted ClickHouse endpoint, and vice versa. Extend + * only from observed model output, never speculatively. + * + * KNOWN LIMITATION (time semantics): iso_timestamp_start/end are ignored + * (fixed-date seeds), so window-correctness of model queries is NOT exercised + * locally. A time-window-discriminating eval needs relative-time seeding. */ const NUMERIC_LOG_ATTRIBUTES = new Set(['response.status_code', 'status_code', 'execution_time_ms']) From d548d65fc8c2baad3abb1876f0effa538c46f7ba Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 21 Jul 2026 11:07:37 +0200 Subject: [PATCH 05/16] platform-lite: route-level tests for the logs read-only guarantee The PR claims data-modifying CTEs are rejected, but the existing tests called the translator/db directly. Exercise the actual HTTP route: normal ClickHouse query returns the {result} shape; WITH x AS (DELETE ... RETURNING *) SELECT is rejected by the read-only transaction with fixture rows provably unchanged; plain non-SELECT hits the 400 prefix gate. --- .../src/management-api/debugging.test.ts | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/platform-lite/src/management-api/debugging.test.ts b/packages/platform-lite/src/management-api/debugging.test.ts index fcf2bc8c..f846811e 100644 --- a/packages/platform-lite/src/management-api/debugging.test.ts +++ b/packages/platform-lite/src/management-api/debugging.test.ts @@ -1,8 +1,9 @@ import { PGlite } from '@electric-sql/pglite' import { afterAll, describe, expect, it } from 'vitest' +import type { ProjectStore } from '../project-store.js' import { LOGS_BASE_SQL, seedLogRow } from '../project/log-seeding.js' -import { compileClickHouseLogsSql } from './debugging.js' +import { compileClickHouseLogsSql, createDebuggingRoutes } from './debugging.js' // Contract test for the ClickHouse-shaped /analytics/endpoints/logs fixture: // the SQL current mcp emits (get_logs presets and query_logs-style aggregation @@ -100,3 +101,56 @@ order by error_count desc` expect(Number((result.rows[0] as { error_count: unknown }).error_count)).toBe(2) }) }) + +// Route-level contract: the read-only guarantee must hold at the HTTP boundary +// where model-authored SQL arrives, not just in the translator. Dedicated DB so +// a failing guard can't poison the other tests' fixture rows. +describe('/v1/projects/:ref/analytics/endpoints/logs route', () => { + const routeDb = new PGlite() + const ready = (async () => { + await routeDb.exec(LOGS_BASE_SQL) + for (const id of ['r1', 'r2']) { + await seedLogRow(routeDb, { + id, + ts: new Date('2026-04-28T10:00:00Z'), + source: 'edge-function', + level: 'error', + message: 'boom', + metadata: { function_id: 'stripe-webhook', status: 500, duration_ms: 100 }, + }) + } + })() + afterAll(() => routeDb.close()) + + const store = { get: (ref: string) => (ref === 'proj' ? { logsDb: routeDb } : undefined) } + const { app } = createDebuggingRoutes(store as unknown as ProjectStore) + const url = (sql: string) => `/v1/projects/proj/analytics/endpoints/logs?sql=${encodeURIComponent(sql)}` + const countRows = async () => + Number((await routeDb.query<{ n: string }>('select count(*) as n from function_edge_logs')).rows[0]!.n) + + it('serves a ClickHouse query with the {result} response shape', async () => { + await ready + const res = await app.request(url("select id, log_attributes['function_id'] as function_id from logs where source = 'function_edge_logs' order by timestamp desc limit 10")) + expect(res.status).toBe(200) + const body = (await res.json()) as { result: unknown[] } + expect(body.result).toHaveLength(2) + }) + + it('rejects a data-modifying CTE and leaves fixture rows intact', async () => { + await ready + const before = await countRows() + const res = await app.request(url('WITH x AS (DELETE FROM function_edge_logs RETURNING *) SELECT count(*) FROM x')) + const body = (await res.json()) as { result: unknown[]; error?: string } + expect(body.error).toMatch(/read-only/i) + expect(body.result).toEqual([]) + expect(await countRows()).toBe(before) + }) + + it('rejects plain non-SELECT statements at the prefix gate', async () => { + await ready + const before = await countRows() + const res = await app.request(url('DELETE FROM function_edge_logs')) + expect(res.status).toBe(400) + expect(await countRows()).toBe(before) + }) +}) From a0305f8f313129427892769a9cc68c7fd0ee6a9d Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 21 Jul 2026 12:46:04 +0200 Subject: [PATCH 06/16] platform-lite: document provenance of the hand-modeled ClickHouse surface Review question on #99: why are these SQL statements defined here instead of imported? Answer, now in-code: the logs relation shape is the hosted platform's Logflare/ClickHouse contract (supabase/platform#35096, platform-internal, no npm artifact); source names track mcp's logsServiceSchema under the pinned MCP_SERVER_VERSION; the OrZero/toString family reimplements ClickHouse builtins; and the verbatim test SQL is frozen observed output on purpose (importing live definitions would make the contract tests tautological). --- .../platform-lite/src/management-api/debugging.ts | 7 +++++++ packages/platform-lite/src/project/log-seeding.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/platform-lite/src/management-api/debugging.ts b/packages/platform-lite/src/management-api/debugging.ts index 8fb85c60..5293cf2f 100644 --- a/packages/platform-lite/src/management-api/debugging.ts +++ b/packages/platform-lite/src/management-api/debugging.ts @@ -124,6 +124,13 @@ export function createDebuggingRoutes(store: ProjectStore): ManagementApiRoutes * would FAIL against the hosted ClickHouse endpoint, and vice versa. Extend * only from observed model output, never speculatively. * + * PROVENANCE: none of this is importable. The real dialect boundary lives in + * the hosted platform's Logflare/ClickHouse backend (supabase/platform#35096, + * platform-internal); mcp ships only tool descriptions, and ClickHouse + * builtins have no npm artifact. The verbatim SQL in debugging.test.ts is + * deliberately FROZEN observed output (regression fixtures) - importing live + * definitions would make those contract tests follow the thing they test. + * * KNOWN LIMITATION (time semantics): iso_timestamp_start/end are ignored * (fixed-date seeds), so window-correctness of model queries is NOT exercised * locally. A time-window-discriminating eval needs relative-time seeding. diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index 58cabe4e..3981a54d 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -96,6 +96,15 @@ CREATE TABLE IF NOT EXISTS storage_logs ( -- log_attributes map. Mirror it so ClickHouse-dialect SQL from current mcp -- (get_logs presets, query_logs) runs with minimal translation. Column-backed -- attributes win over seeded metadata; nulls fall back to metadata keys. +-- +-- Provenance (nothing here is importable, so this is hand-modeled): +-- * The 'logs' relation shape is the hosted platform's Logflare/ClickHouse +-- contract (supabase/platform#35096). It is platform-internal: no npm +-- package exports the schema, so fixtures must model it, exactly like +-- every other platform-lite emulation in this package. +-- * The 'source' names must match mcp's logsServiceSchema +-- (packages/mcp-server-supabase/src/tools/logs.ts). Resync this view when +-- the pinned MCP_SERVER_VERSION moves or that schema changes. CREATE VIEW logs AS SELECT id, identifier, timestamp, ts, event_message, message, level, level AS severity_text, 'edge_logs'::text AS source, metadata || jsonb_strip_nulls(jsonb_build_object('identifier', identifier, 'request.method', method, 'request.path', path, 'response.status_code', status_code)) AS log_attributes @@ -122,8 +131,11 @@ CREATE VIEW logs AS -- ClickHouse numeric-cast family, as models genuinely emit it in query_logs -- SQL (e.g. countIf(toInt32OrZero(log_attributes['status']) >= 400)). +-- These are pg reimplementations of ClickHouse BUILTINS (also not importable +-- from anywhere); signatures follow clickhouse.com/docs/sql-reference. -- ClickHouse semantics: parse the value, 0 when it isn't a number. Text and -- numeric overloads cover both raw jsonb access and the translator's casts. +-- Grown strictly from observed model output - see debugging.ts translator note. CREATE FUNCTION toInt32OrZero(v text) RETURNS numeric AS $ch$ BEGIN RETURN coalesce(v::numeric, 0); EXCEPTION WHEN others THEN RETURN 0; END $ch$ LANGUAGE plpgsql IMMUTABLE; From 694bcc046e101f0d449b7d01fde75fbb0047d997 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 21 Jul 2026 14:00:32 +0200 Subject: [PATCH 07/16] platform-lite: correct provenance conflation + logsServiceSchema drift tripwire Review follow-up on #99: the earlier provenance note (and my reply) claimed nothing was importable - wrong on one count. The pinned mcp package DOES export logsServiceSchema from its /platform entrypoint; what it enumerates is the service-preset namespace, not the unified-stream source names the view discriminates on (those exist only in preset SQL strings and the query_logs description). Comment corrected, and the importable artifact is now used for what it's genuinely good for: a drift tripwire that fails loudly when a version bump changes the service enum, pointing at what to resync. --- .../src/management-api/debugging.test.ts | 21 +++++++++++++++++++ .../platform-lite/src/project/log-seeding.ts | 8 ++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/platform-lite/src/management-api/debugging.test.ts b/packages/platform-lite/src/management-api/debugging.test.ts index f846811e..be3fc77f 100644 --- a/packages/platform-lite/src/management-api/debugging.test.ts +++ b/packages/platform-lite/src/management-api/debugging.test.ts @@ -1,4 +1,5 @@ import { PGlite } from '@electric-sql/pglite' +import { logsServiceSchema } from '@supabase/mcp-server-supabase/platform' import { afterAll, describe, expect, it } from 'vitest' import type { ProjectStore } from '../project-store.js' @@ -154,3 +155,23 @@ describe('/v1/projects/:ref/analytics/endpoints/logs route', () => { expect(await countRows()).toBe(before) }) }) + +// Drift tripwire against the PINNED mcp package (the one importable artifact +// here). The unified-stream 'source' names in the logs VIEW are not exported +// by mcp as data, but the service-preset enum is; when a version bump +// adds/renames services (e.g. edge-function-runtime already exists on mcp +// main), this fails loudly and says: resync the logs view sources, the +// translator, and this list. +describe('pinned mcp logsServiceSchema alignment', () => { + it('matches the service presets this fixture was modeled against', () => { + expect([...logsServiceSchema.options].sort()).toEqual([ + 'api', + 'auth', + 'branch-action', + 'edge-function', + 'postgres', + 'realtime', + 'storage', + ]) + }) +}) diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index 3981a54d..af09e127 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -102,9 +102,11 @@ CREATE TABLE IF NOT EXISTS storage_logs ( -- contract (supabase/platform#35096). It is platform-internal: no npm -- package exports the schema, so fixtures must model it, exactly like -- every other platform-lite emulation in this package. --- * The 'source' names must match mcp's logsServiceSchema --- (packages/mcp-server-supabase/src/tools/logs.ts). Resync this view when --- the pinned MCP_SERVER_VERSION moves or that schema changes. +-- * The 'source' names are the unified-stream sources referenced by mcp's +-- preset SQL and the query_logs sql description (not exported as data; +-- the exported logsServiceSchema enumerates service PRESETS, a different +-- namespace - see the drift tripwire in debugging.test.ts). Resync this +-- view when the pinned MCP_SERVER_VERSION moves. CREATE VIEW logs AS SELECT id, identifier, timestamp, ts, event_message, message, level, level AS severity_text, 'edge_logs'::text AS source, metadata || jsonb_strip_nulls(jsonb_build_object('identifier', identifier, 'request.method', method, 'request.path', path, 'response.status_code', status_code)) AS log_attributes From 8f71f95a0cb922a425c90adab9afebf8050bf9bc Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 21 Jul 2026 14:03:03 +0200 Subject: [PATCH 08/16] platform-lite: drop the logsServiceSchema tripwire, keep corrected provenance Second thought on the tripwire: it asserted the service-PRESET enum, which is a different namespace from the view's source names, so its failure could not demonstrate view staleness. Worse, it imported the resolved devDependency (^0.8.1 -> 0.8.2 today) while the harness runs the MCP_SERVER_VERSION pin (0.8.1), so it guarded a version the fixture never exercises. The verbatim frozen preset SQL in these tests remains the honest alignment contract. The provenance comment now records the exported-schema nuance and why importing it would track the wrong artifact. --- .../src/management-api/debugging.test.ts | 21 ------------------- .../platform-lite/src/project/log-seeding.ts | 12 ++++++----- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/packages/platform-lite/src/management-api/debugging.test.ts b/packages/platform-lite/src/management-api/debugging.test.ts index be3fc77f..f846811e 100644 --- a/packages/platform-lite/src/management-api/debugging.test.ts +++ b/packages/platform-lite/src/management-api/debugging.test.ts @@ -1,5 +1,4 @@ import { PGlite } from '@electric-sql/pglite' -import { logsServiceSchema } from '@supabase/mcp-server-supabase/platform' import { afterAll, describe, expect, it } from 'vitest' import type { ProjectStore } from '../project-store.js' @@ -155,23 +154,3 @@ describe('/v1/projects/:ref/analytics/endpoints/logs route', () => { expect(await countRows()).toBe(before) }) }) - -// Drift tripwire against the PINNED mcp package (the one importable artifact -// here). The unified-stream 'source' names in the logs VIEW are not exported -// by mcp as data, but the service-preset enum is; when a version bump -// adds/renames services (e.g. edge-function-runtime already exists on mcp -// main), this fails loudly and says: resync the logs view sources, the -// translator, and this list. -describe('pinned mcp logsServiceSchema alignment', () => { - it('matches the service presets this fixture was modeled against', () => { - expect([...logsServiceSchema.options].sort()).toEqual([ - 'api', - 'auth', - 'branch-action', - 'edge-function', - 'postgres', - 'realtime', - 'storage', - ]) - }) -}) diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index af09e127..0c3124a1 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -97,16 +97,18 @@ CREATE TABLE IF NOT EXISTS storage_logs ( -- (get_logs presets, query_logs) runs with minimal translation. Column-backed -- attributes win over seeded metadata; nulls fall back to metadata keys. -- --- Provenance (nothing here is importable, so this is hand-modeled): +-- Provenance (hand-modeled; nothing usable is importable): -- * The 'logs' relation shape is the hosted platform's Logflare/ClickHouse -- contract (supabase/platform#35096). It is platform-internal: no npm -- package exports the schema, so fixtures must model it, exactly like -- every other platform-lite emulation in this package. -- * The 'source' names are the unified-stream sources referenced by mcp's --- preset SQL and the query_logs sql description (not exported as data; --- the exported logsServiceSchema enumerates service PRESETS, a different --- namespace - see the drift tripwire in debugging.test.ts). Resync this --- view when the pinned MCP_SERVER_VERSION moves. +-- preset SQL and the query_logs sql description - not exported as data. +-- (mcp's /platform entrypoint DOES export logsServiceSchema, but that +-- enumerates service PRESETS, a different namespace; and the resolved +-- package (^0.8.1) can drift ahead of the MCP_SERVER_VERSION pin the +-- harness actually runs, so importing it would track the wrong artifact.) +-- Resync this view when the pinned MCP_SERVER_VERSION moves. CREATE VIEW logs AS SELECT id, identifier, timestamp, ts, event_message, message, level, level AS severity_text, 'edge_logs'::text AS source, metadata || jsonb_strip_nulls(jsonb_build_object('identifier', identifier, 'request.method', method, 'request.path', path, 'response.status_code', status_code)) AS log_attributes From 32f9ce25741682196bbee0a879f1f3e0320dabb8 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 21 Jul 2026 14:48:04 +0200 Subject: [PATCH 09/16] platform-lite: cite both platform PRs, note in-review status Verified against supabase/platform directly: #35096 (getLogs -> logs.all.otel unified stream, ClickHouse dialect) and #35970 (query_logs passthrough, timestamps normalized platform-side) are both OPEN, so the contract this fixture models is what mcp main is written against, not what hosted serves today. The 35970 e2e spec uses the same source vocabulary as this view (postgres_logs), which is a good consistency signal. --- packages/platform-lite/src/project/log-seeding.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index 0c3124a1..c5795124 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -99,8 +99,12 @@ CREATE TABLE IF NOT EXISTS storage_logs ( -- -- Provenance (hand-modeled; nothing usable is importable): -- * The 'logs' relation shape is the hosted platform's Logflare/ClickHouse --- contract (supabase/platform#35096). It is platform-internal: no npm --- package exports the schema, so fixtures must model it, exactly like +-- contract, established by the (in-review as of 2026-07-21) platform PRs +-- supabase/platform#35096 (unified logs.all.otel stream, CH dialect) and +-- #35970 (query_logs passthrough endpoint; timestamps normalized +-- platform-side). mcp main is written against that contract, so the +-- fixture models it. It is platform-internal either way: no npm package +-- exports the schema, so fixtures must model it by hand, exactly like -- every other platform-lite emulation in this package. -- * The 'source' names are the unified-stream sources referenced by mcp's -- preset SQL and the query_logs sql description - not exported as data. From a52cfab79713f3abaf606121793d860e18da2b0b Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 21 Jul 2026 14:52:14 +0200 Subject: [PATCH 10/16] platform-lite: separate per-PR platform capabilities in provenance #35096 backs the getLogs PRESET path (logs.all.otel + CH dialect) that mcp main emits post-#326; #35970 backs the custom-SQL passthrough that the still open mcp#333 targets - main does not depend on it. Hosted serves the /analytics/endpoints/logs route today; it is the per-PR capabilities that are pending, not the route. --- .../platform-lite/src/project/log-seeding.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index c5795124..c31cb4d8 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -99,13 +99,17 @@ CREATE TABLE IF NOT EXISTS storage_logs ( -- -- Provenance (hand-modeled; nothing usable is importable): -- * The 'logs' relation shape is the hosted platform's Logflare/ClickHouse --- contract, established by the (in-review as of 2026-07-21) platform PRs --- supabase/platform#35096 (unified logs.all.otel stream, CH dialect) and --- #35970 (query_logs passthrough endpoint; timestamps normalized --- platform-side). mcp main is written against that contract, so the --- fixture models it. It is platform-internal either way: no npm package --- exports the schema, so fixtures must model it by hand, exactly like --- every other platform-lite emulation in this package. +-- contract. Hosted serves the /analytics/endpoints/logs route today; the +-- capabilities are landing per-PR (both in review as of 2026-07-21): +-- - supabase/platform#35096: logs.all.otel unified stream + ClickHouse +-- dialect for the getLogs PRESETS - what mcp main (post-#326) emits. +-- - supabase/platform#35970: custom-SQL passthrough (timestamps +-- normalized platform-side) - what mcp#333's query_logs (still open) +-- targets; current mcp main does not depend on it. +-- The fixture models what current mcp emits against that contract. It is +-- platform-internal either way: no npm package exports the schema, so +-- fixtures must model it by hand, exactly like every other platform-lite +-- emulation in this package. -- * The 'source' names are the unified-stream sources referenced by mcp's -- preset SQL and the query_logs sql description - not exported as data. -- (mcp's /platform entrypoint DOES export logsServiceSchema, but that From 864f4885bfe7e60e5e6e5d7a14731e69af395e0e Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 21 Jul 2026 15:18:45 +0200 Subject: [PATCH 11/16] platform-lite: disambiguate current-main vs mcp#333 in the view header The header bundled query_logs into 'current mcp' while the provenance bullets below correctly note #333 is still open; both now say: current main emits the get_logs presets, the #333 branch emits query_logs, and the fixture models both. --- packages/platform-lite/src/project/log-seeding.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index c31cb4d8..4167aa60 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -94,7 +94,8 @@ CREATE TABLE IF NOT EXISTS storage_logs ( -- Unified ClickHouse-shaped stream: the hosted /analytics/endpoints/logs -- endpoint exposes one 'logs' relation with a 'source' discriminator and a -- log_attributes map. Mirror it so ClickHouse-dialect SQL from current mcp --- (get_logs presets, query_logs) runs with minimal translation. Column-backed +-- (get_logs presets) and from mcp#333's query_logs runs with minimal +-- translation. Column-backed -- attributes win over seeded metadata; nulls fall back to metadata keys. -- -- Provenance (hand-modeled; nothing usable is importable): @@ -106,7 +107,8 @@ CREATE TABLE IF NOT EXISTS storage_logs ( -- - supabase/platform#35970: custom-SQL passthrough (timestamps -- normalized platform-side) - what mcp#333's query_logs (still open) -- targets; current mcp main does not depend on it. --- The fixture models what current mcp emits against that contract. It is +-- The fixture models both current main and the #333 validation branch +-- against that contract. It is -- platform-internal either way: no npm package exports the schema, so -- fixtures must model it by hand, exactly like every other platform-lite -- emulation in this package. From b759ca25fe6fdbc23f0a068aa98a551e7364de98 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Thu, 23 Jul 2026 15:12:46 +0200 Subject: [PATCH 12/16] =?UTF-8?q?platform-lite:=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20{message}=20on=20400,=20pin=20CTE=20status,=20reloc?= =?UTF-8?q?ate=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 400 prefix-reject body now carries a message key: mcp's assertSuccess parses non-2xx bodies as {message}, so the informative read-only text was collapsing to the generic 'Failed to fetch logs' fallback (error kept for shape consistency with the 200 SQL-error path) - CTE-reject test pins status 200: the prefix gate's 400 message also matches /read-only/i, so unifying the rejection paths would otherwise leave the read-only transaction guard silently untested - move src/management-api/debugging.test.ts -> test/clickhouse-logs.test.ts: platform-lite tests live under test/, and the src placement collided on basename with the existing test/debugging.test.ts --- .../src/management-api/debugging.ts | 9 +++++++-- .../clickhouse-logs.test.ts} | 17 ++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) rename packages/platform-lite/{src/management-api/debugging.test.ts => test/clickhouse-logs.test.ts} (88%) diff --git a/packages/platform-lite/src/management-api/debugging.ts b/packages/platform-lite/src/management-api/debugging.ts index 5293cf2f..12c47810 100644 --- a/packages/platform-lite/src/management-api/debugging.ts +++ b/packages/platform-lite/src/management-api/debugging.ts @@ -53,10 +53,15 @@ export function createDebuggingRoutes(store: ProjectStore): ManagementApiRoutes // The hosted endpoint is read-only server-side; enforce the same contract on // model-authored SQL. The prefix check only shapes the error message — the // REAL enforcement is the read-only transaction below, which postgres applies - // to every statement including data-modifying CTEs. + // to every statement including data-modifying CTEs. The 400 body carries + // `message` because mcp's assertSuccess parses non-2xx bodies as {message} + // (the management-API error envelope) — without it the model only sees the + // generic "Failed to fetch logs" fallback; `error` kept for shape + // consistency with the 200 SQL-error path. const stmt = sql.trim().replace(/;+\s*$/, '') if (stmt.includes(';') || !/^\s*(select|with)\b/i.test(stmt)) { - return c.json({ result: [], error: 'only a single read-only SELECT statement is supported' }, 400) + const message = 'only a single read-only SELECT statement is supported' + return c.json({ result: [], error: message, message }, 400) } try { diff --git a/packages/platform-lite/src/management-api/debugging.test.ts b/packages/platform-lite/test/clickhouse-logs.test.ts similarity index 88% rename from packages/platform-lite/src/management-api/debugging.test.ts rename to packages/platform-lite/test/clickhouse-logs.test.ts index f846811e..223f50e4 100644 --- a/packages/platform-lite/src/management-api/debugging.test.ts +++ b/packages/platform-lite/test/clickhouse-logs.test.ts @@ -1,9 +1,9 @@ import { PGlite } from '@electric-sql/pglite' import { afterAll, describe, expect, it } from 'vitest' -import type { ProjectStore } from '../project-store.js' -import { LOGS_BASE_SQL, seedLogRow } from '../project/log-seeding.js' -import { compileClickHouseLogsSql, createDebuggingRoutes } from './debugging.js' +import type { ProjectStore } from '../src/project-store.js' +import { LOGS_BASE_SQL, seedLogRow } from '../src/project/log-seeding.js' +import { compileClickHouseLogsSql, createDebuggingRoutes } from '../src/management-api/debugging.js' // Contract test for the ClickHouse-shaped /analytics/endpoints/logs fixture: // the SQL current mcp emits (get_logs presets and query_logs-style aggregation @@ -140,6 +140,11 @@ describe('/v1/projects/:ref/analytics/endpoints/logs route', () => { await ready const before = await countRows() const res = await app.request(url('WITH x AS (DELETE FROM function_edge_logs RETURNING *) SELECT count(*) FROM x')) + // Pin the status: 200 proves this went through the SQL-error path (the + // read-only transaction), not the prefix gate's 400 — the gate's message + // also matches /read-only/i, so without this a refactor unifying the two + // rejection paths would leave the transaction guard silently untested. + expect(res.status).toBe(200) const body = (await res.json()) as { result: unknown[]; error?: string } expect(body.error).toMatch(/read-only/i) expect(body.result).toEqual([]) @@ -151,6 +156,12 @@ describe('/v1/projects/:ref/analytics/endpoints/logs route', () => { const before = await countRows() const res = await app.request(url('DELETE FROM function_edge_logs')) expect(res.status).toBe(400) + // mcp's assertSuccess parses non-2xx bodies as {message} — pin that key so + // the informative text reaches the model instead of the generic fallback. + const body = (await res.json()) as { result: unknown[]; error?: string; message?: string } + expect(body.message).toMatch(/read-only SELECT/i) + expect(body.error).toBe(body.message) + expect(body.result).toEqual([]) expect(await countRows()).toBe(before) }) }) From 51dcf922b3de8d76f2c3ac1ac0ccb3b3ad707b22 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Thu, 23 Jul 2026 15:21:25 +0200 Subject: [PATCH 13/16] platform-lite: hosted-faithful map values + review minors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proposal 4 (review): drop the implicit numeric cast on response.status_code/status_code/execution_time_ms map access. Hosted ClickHouse map values are String, so a bare comparison like log_attributes['response.status_code'] >= 500 errors there — the fixture now errors identically instead of silently accepting SQL that would fail hosted (eval-greens-locally hazard). Models adapt by wrapping in toInt32OrZero, exactly as the frozen fixtures show; a new test pins the error friction, and the constructed query_logs-style test now wraps its comparison like a hosted-correct query must. Minors: type the read-only transaction result (cast gone); parametrize the two verbatim PR-333 fixtures with it.each; assert function_id/level values in the runtime-preset test instead of bare row count; typed Pick<> partial for the fake store; document the two unmodeled preset sources (workflow_run_logs, realtime_logs) in the view header. --- .../src/management-api/debugging.ts | 18 ++--- .../platform-lite/src/project/log-seeding.ts | 4 ++ .../test/clickhouse-logs.test.ts | 70 +++++++++++++------ 3 files changed, 61 insertions(+), 31 deletions(-) diff --git a/packages/platform-lite/src/management-api/debugging.ts b/packages/platform-lite/src/management-api/debugging.ts index 12c47810..51e931aa 100644 --- a/packages/platform-lite/src/management-api/debugging.ts +++ b/packages/platform-lite/src/management-api/debugging.ts @@ -68,9 +68,9 @@ export function createDebuggingRoutes(store: ProjectStore): ManagementApiRoutes const compiled = compileClickHouseLogsSql(stmt) const result = await project.logsDb.transaction(async (tx) => { await tx.exec('SET TRANSACTION READ ONLY') - return tx.query(compiled) + return tx.query>(compiled) }) - return c.json({ result: (result as { rows: unknown[] }).rows }) + return c.json({ result: result.rows }) } catch (err) { const message = err instanceof Error ? err.message : String(err) return c.json({ result: [], error: message }) @@ -119,8 +119,10 @@ export function createDebuggingRoutes(store: ProjectStore): ManagementApiRoutes * Translate ClickHouse-dialect SQL (as current mcp emits for the unified logs * stream) into PGlite SQL against the 'logs' VIEW. The supported surface is * DELIBERATELY partial — exactly what models have been observed to emit: - * log_attributes['k'] -> (log_attributes->>'k') (numeric keys get a cast - * so agent-written comparisons like >= 500 work) + * log_attributes['k'] -> (log_attributes->>'k') (always text — hosted + * ClickHouse map values are String too, so a bare numeric comparison like + * log_attributes['status'] >= 400 errors here exactly as it does there; + * models adapt by wrapping in toInt32OrZero, as observed) * countIf(cond) -> count(*) FILTER (WHERE cond) * toInt32OrZero/toInt64OrZero/toUInt32OrZero/toString -> SQL shims in * LOGS_BASE_SQL (log-seeding.ts) @@ -140,15 +142,9 @@ export function createDebuggingRoutes(store: ProjectStore): ManagementApiRoutes * (fixed-date seeds), so window-correctness of model queries is NOT exercised * locally. A time-window-discriminating eval needs relative-time seeding. */ -const NUMERIC_LOG_ATTRIBUTES = new Set(['response.status_code', 'status_code', 'execution_time_ms']) - export function compileClickHouseLogsSql(sql: string): string { return sql - .replace(/\blog_attributes\['([^']+)'\]/gi, (_m, key: string) => - NUMERIC_LOG_ATTRIBUTES.has(key) - ? `((log_attributes->>'${key}')::numeric)` - : `(log_attributes->>'${key}')` - ) + .replace(/\blog_attributes\['([^']+)'\]/gi, (_m, key: string) => `(log_attributes->>'${key}')`) .replace(/\bcountIf\s*\(/gi, 'count(*) FILTER (WHERE ') } diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index 4167aa60..4274d07c 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -119,6 +119,10 @@ CREATE TABLE IF NOT EXISTS storage_logs ( -- package (^0.8.1) can drift ahead of the MCP_SERVER_VERSION pin the -- harness actually runs, so importing it would track the wrong artifact.) -- Resync this view when the pinned MCP_SERVER_VERSION moves. +-- * UNMODELED preset sources: workflow_run_logs (branch-action) and +-- realtime_logs (realtime) have no backing table in platform-lite, so +-- those presets return 0 rows locally — a silent gap, not an error. Add +-- tables + seeds before trusting a branch-action/realtime logs eval. CREATE VIEW logs AS SELECT id, identifier, timestamp, ts, event_message, message, level, level AS severity_text, 'edge_logs'::text AS source, metadata || jsonb_strip_nulls(jsonb_build_object('identifier', identifier, 'request.method', method, 'request.path', path, 'response.status_code', status_code)) AS log_attributes diff --git a/packages/platform-lite/test/clickhouse-logs.test.ts b/packages/platform-lite/test/clickhouse-logs.test.ts index 223f50e4..187e22bf 100644 --- a/packages/platform-lite/test/clickhouse-logs.test.ts +++ b/packages/platform-lite/test/clickhouse-logs.test.ts @@ -2,6 +2,7 @@ import { PGlite } from '@electric-sql/pglite' import { afterAll, describe, expect, it } from 'vitest' import type { ProjectStore } from '../src/project-store.js' +import type { ProjectInstance } from '../src/project/ProjectInstance.js' import { LOGS_BASE_SQL, seedLogRow } from '../src/project/log-seeding.js' import { compileClickHouseLogsSql, createDebuggingRoutes } from '../src/management-api/debugging.js' @@ -53,7 +54,7 @@ describe('compileClickHouseLogsSql + unified logs view', () => { it('runs a query_logs-style countIf aggregation (top error function)', async () => { const sql = `select log_attributes['function_id'] as function_id, - countIf(log_attributes['response.status_code'] >= 500) as error_count, + countIf(toInt32OrZero(log_attributes['response.status_code']) >= 500) as error_count, count(*) as total_count from logs where source = 'function_edge_logs' @@ -69,36 +70,61 @@ describe('compileClickHouseLogsSql + unified logs view', () => { expect(Number(result.rows[1]!.error_count)).toBe(1) }) + it('surfaces an error for a bare numeric comparison on a map value (hosted-faithful)', async () => { + // Hosted ClickHouse map values are String, so a bare `>= 500` comparison is + // a type error there — it must error here too. The surfaced error is the + // friction that teaches the model to wrap in toInt32OrZero (exactly what + // the verbatim fixtures below show it doing). + await expect( + logsDb.query( + compileClickHouseLogsSql( + "select countIf(log_attributes['response.status_code'] >= 500) as n from logs where source = 'function_edge_logs'" + ) + ) + ).rejects.toThrow(/operator does not exist/i) + }) + it('runs the runtime (function_logs) preset source', async () => { - const result = await logsDb.query( + const result = await logsDb.query<{ function_id: string; level: string; severity_text: string }>( compileClickHouseLogsSql( `select id, timestamp, event_message, severity_text, log_attributes['level'] as level, log_attributes['function_id'] as function_id from logs where source = 'function_logs' order by timestamp desc limit 10` ) ) - expect(result.rows.length).toBeGreaterThan(0) + expect(result.rows.map((r) => r.function_id).sort()).toEqual([ + 'send-email', + 'send-email', + 'stripe-webhook', + 'stripe-webhook', + 'stripe-webhook', + ]) + for (const row of result.rows) { + expect(['error', 'info']).toContain(row.level) + expect(row.severity_text).toBe(row.level) + } }) - it('runs the exact ClickHouse SQL claude-sonnet-5 emitted in the PR-333 A/B (toInt32OrZero)', async () => { - // verbatim model output from results-ab/investigate-logs-001-top-error-function.treatment.json - const sql = `select log_attributes['function_id'] as function_id, + + // Both cases are verbatim model output from the PR-333 A/B + // (results-ab/investigate-logs-001-top-error-function.treatment.json): + // frozen regression fixtures, one per ClickHouse builtin shim they exercise. + it.each([ + [ + 'toInt32OrZero', + `select log_attributes['function_id'] as function_id, count(*) as total_events, countIf(level = 'error' or toInt32OrZero(log_attributes['status']) >= 400) as error_count from logs where source = 'function_edge_logs' group by function_id -order by error_count desc` - const result = await logsDb.query<{ function_id: string; error_count: string | number; total_count: unknown }>( - compileClickHouseLogsSql(sql) - ) - expect(result.rows[0]).toMatchObject({ function_id: 'stripe-webhook' }) - expect(Number((result.rows[0] as { error_count: unknown }).error_count)).toBe(2) - }) - - it('runs the toString-nested ClickHouse SQL from the treatment rerun', async () => { - // verbatim model output (rerun call 4): toString wrapped inside toInt32OrZero - const sql = `select log_attributes['function_id'] as function_id, count(*) as total_events, countIf(toInt32OrZero(toString(log_attributes['status'])) >= 400 or level = 'error') as error_count from logs where source = 'function_edge_logs' group by function_id order by error_count desc` +order by error_count desc`, + ], + [ + 'toString nested inside toInt32OrZero (rerun call 4)', + `select log_attributes['function_id'] as function_id, count(*) as total_events, countIf(toInt32OrZero(toString(log_attributes['status'])) >= 400 or level = 'error') as error_count from logs where source = 'function_edge_logs' group by function_id order by error_count desc`, + ], + ])('runs the exact ClickHouse SQL claude-sonnet-5 emitted in the PR-333 A/B (%s)', async (_label, sql) => { const result = await logsDb.query<{ function_id: string; error_count: unknown }>(compileClickHouseLogsSql(sql)) expect(result.rows[0]).toMatchObject({ function_id: 'stripe-webhook' }) - expect(Number((result.rows[0] as { error_count: unknown }).error_count)).toBe(2) + expect(Number(result.rows[0]!.error_count)).toBe(2) }) }) @@ -122,8 +148,12 @@ describe('/v1/projects/:ref/analytics/endpoints/logs route', () => { })() afterAll(() => routeDb.close()) - const store = { get: (ref: string) => (ref === 'proj' ? { logsDb: routeDb } : undefined) } - const { app } = createDebuggingRoutes(store as unknown as ProjectStore) + // Minimal fake: the routes only call store.get(ref) and touch project.logsDb. + // Pick<> keeps the used surface type-checked; the single cast is confined to + // the fake project object, whose other ProjectInstance fields are never read. + const fakeProject = { logsDb: routeDb } as Partial as ProjectInstance + const store: Pick = { get: (ref) => (ref === 'proj' ? fakeProject : undefined) } + const { app } = createDebuggingRoutes(store as ProjectStore) const url = (sql: string) => `/v1/projects/proj/analytics/endpoints/logs?sql=${encodeURIComponent(sql)}` const countRows = async () => Number((await routeDb.query<{ n: string }>('select count(*) as n from function_edge_logs')).rows[0]!.n) From 89b814d592369e033454b8e3c7cb8bdd46cca910 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Thu, 23 Jul 2026 15:28:01 +0200 Subject: [PATCH 14/16] platform-lite: close out review minors properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openapi.json: advertise /analytics/endpoints/logs — spliced the single generated path entry (upstream does advertise it; AnalyticsResponse ref already present) instead of taking the full regen's unrelated drift; pinned alongside logs.all in openapi.test.ts - unmodeled sources now error loudly: compileClickHouseLogsSql rejects queries naming workflow_run_logs/realtime_logs (no backing table) so a branch-action/realtime eval fails visibly instead of reading a silent 0-row result as 'no logs'; tested at translator and HTTP level - route test store: real init-free ProjectInstance in a real Map — the exact ProjectStore shape, both casts gone --- .../src/management-api/debugging.ts | 14 +++ .../src/management-api/openapi.json | 101 +++++++++++++++++- .../platform-lite/src/management-api/types.ts | 83 ++++++++++++++ .../platform-lite/src/project/log-seeding.ts | 7 +- .../test/clickhouse-logs.test.ts | 44 +++++--- packages/platform-lite/test/openapi.test.ts | 16 ++- 6 files changed, 248 insertions(+), 17 deletions(-) diff --git a/packages/platform-lite/src/management-api/debugging.ts b/packages/platform-lite/src/management-api/debugging.ts index 51e931aa..77e0387d 100644 --- a/packages/platform-lite/src/management-api/debugging.ts +++ b/packages/platform-lite/src/management-api/debugging.ts @@ -131,6 +131,12 @@ export function createDebuggingRoutes(store: ProjectStore): ManagementApiRoutes * would FAIL against the hosted ClickHouse endpoint, and vice versa. Extend * only from observed model output, never speculatively. * + * UNMODELED sources error loudly: workflow_run_logs (branch-action preset) and + * realtime_logs (realtime preset) have no backing table in platform-lite, so a + * query naming them throws here instead of silently returning 0 rows — an + * empty result would read as "no logs", green-lighting an eval the fixture + * cannot actually serve. The error surfaces to the model like any other. + * * PROVENANCE: none of this is importable. The real dialect boundary lives in * the hosted platform's Logflare/ClickHouse backend (supabase/platform#35096, * platform-internal); mcp ships only tool descriptions, and ClickHouse @@ -142,7 +148,15 @@ export function createDebuggingRoutes(store: ProjectStore): ManagementApiRoutes * (fixed-date seeds), so window-correctness of model queries is NOT exercised * locally. A time-window-discriminating eval needs relative-time seeding. */ +const UNMODELED_SOURCES = /\b(workflow_run_logs|realtime_logs)\b/i + export function compileClickHouseLogsSql(sql: string): string { + const unmodeled = UNMODELED_SOURCES.exec(sql) + if (unmodeled) { + throw new Error( + `source '${unmodeled[1]}' is not modeled by platform-lite — no backing table, so results would be silently empty` + ) + } return sql .replace(/\blog_attributes\['([^']+)'\]/gi, (_m, key: string) => `(log_attributes->>'${key}')`) .replace(/\bcountIf\s*\(/gi, 'count(*) FILTER (WHERE ') diff --git a/packages/platform-lite/src/management-api/openapi.json b/packages/platform-lite/src/management-api/openapi.json index 67d8f5dc..50efcc77 100644 --- a/packages/platform-lite/src/management-api/openapi.json +++ b/packages/platform-lite/src/management-api/openapi.json @@ -984,7 +984,7 @@ "/v1/projects/{ref}/analytics/endpoints/logs.all": { "get": { "description": "Executes a SQL query on the project's logs.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nNote: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer:~:text=logs%20from%20the-,Sources,-drop%2Ddown%3A) for all available sources. \n", - "operationId": "v1-get-project-logs", + "operationId": "v1-get-project-logs-all", "parameters": [ { "name": "ref", @@ -1076,6 +1076,105 @@ "x-oauth-scope": "analytics:read" } }, + "/v1/projects/{ref}/analytics/endpoints/logs": { + "get": { + "deprecated": false, + "description": "Executes an SQL or LQL query on the project's unified logs stream.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nFilter by the `source` column to specify specific log sources, such as edge_logs, postgres_logs, etc.\n\nNote: SQL must be written in **ClickHouse SQL dialect**.\n", + "operationId": "v1-get-project-logs", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + }, + { + "name": "sql", + "required": false, + "in": "query", + "description": "Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details.", + "schema": { + "type": "string" + } + }, + { + "name": "iso_timestamp_start", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "example": "2025-03-01T00:00:00Z", + "type": "string" + } + }, + { + "name": "iso_timestamp_end", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "example": "2025-03-01T23:59:59Z", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "description": "Usage exceeded. Enable additional usage to continue querying" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Gets all project's logs in a single log stream", + "tags": [ + "Analytics" + ], + "x-badges": [ + { + "name": "OAuth scope: analytics:read", + "position": "after" + } + ], + "x-endpoint-owners": [ + "analytics" + ], + "x-fga-permissions": [ + [ + "analytics_logs_read" + ] + ], + "x-oauth-scope": "analytics:read" + } + }, "/v1/projects/{ref}/advisors/security": { "get": { "deprecated": true, diff --git a/packages/platform-lite/src/management-api/types.ts b/packages/platform-lite/src/management-api/types.ts index 036caf17..ac07b7ea 100644 --- a/packages/platform-lite/src/management-api/types.ts +++ b/packages/platform-lite/src/management-api/types.ts @@ -1265,6 +1265,34 @@ export interface paths { * * Note: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer:~:text=logs%20from%20the-,Sources,-drop%2Ddown%3A) for all available sources. */ + get: operations["v1-get-project-logs-all"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/projects/{ref}/analytics/endpoints/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Gets all project's logs in a single log stream + * @description Executes an SQL or LQL query on the project's unified logs stream. + * + * Either the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided. + * If both are not provided, only the last 1 minute of logs will be queried. + * The timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown. + * + * Filter by the `source` column to specify specific log sources, such as edge_logs, postgres_logs, etc. + * + * Note: SQL must be written in **ClickHouse SQL dialect**. + */ get: operations["v1-get-project-logs"]; put?: never; post?: never; @@ -9436,6 +9464,54 @@ export interface operations { }; }; }; + "v1-get-project-logs-all": { + parameters: { + query?: { + /** @description Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details. */ + sql?: string; + iso_timestamp_start?: string; + iso_timestamp_end?: string; + }; + header?: never; + path: { + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AnalyticsResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; "v1-get-project-logs": { parameters: { query?: { @@ -9468,6 +9544,13 @@ export interface operations { }; content?: never; }; + /** @description Usage exceeded. Enable additional usage to continue querying */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Forbidden action */ 403: { headers: { diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index 4274d07c..add6f6b4 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -120,9 +120,10 @@ CREATE TABLE IF NOT EXISTS storage_logs ( -- harness actually runs, so importing it would track the wrong artifact.) -- Resync this view when the pinned MCP_SERVER_VERSION moves. -- * UNMODELED preset sources: workflow_run_logs (branch-action) and --- realtime_logs (realtime) have no backing table in platform-lite, so --- those presets return 0 rows locally — a silent gap, not an error. Add --- tables + seeds before trusting a branch-action/realtime logs eval. +-- realtime_logs (realtime) have no backing table in platform-lite. +-- compileClickHouseLogsSql (debugging.ts) rejects queries naming them so +-- they error loudly instead of silently returning 0 rows; model them with +-- real tables + seeds before running a branch-action/realtime logs eval. CREATE VIEW logs AS SELECT id, identifier, timestamp, ts, event_message, message, level, level AS severity_text, 'edge_logs'::text AS source, metadata || jsonb_strip_nulls(jsonb_build_object('identifier', identifier, 'request.method', method, 'request.path', path, 'response.status_code', status_code)) AS log_attributes diff --git a/packages/platform-lite/test/clickhouse-logs.test.ts b/packages/platform-lite/test/clickhouse-logs.test.ts index 187e22bf..23811882 100644 --- a/packages/platform-lite/test/clickhouse-logs.test.ts +++ b/packages/platform-lite/test/clickhouse-logs.test.ts @@ -2,7 +2,7 @@ import { PGlite } from '@electric-sql/pglite' import { afterAll, describe, expect, it } from 'vitest' import type { ProjectStore } from '../src/project-store.js' -import type { ProjectInstance } from '../src/project/ProjectInstance.js' +import { ProjectInstance } from '../src/project/ProjectInstance.js' import { LOGS_BASE_SQL, seedLogRow } from '../src/project/log-seeding.js' import { compileClickHouseLogsSql, createDebuggingRoutes } from '../src/management-api/debugging.js' @@ -84,6 +84,18 @@ describe('compileClickHouseLogsSql + unified logs view', () => { ).rejects.toThrow(/operator does not exist/i) }) + it.each(['workflow_run_logs', 'realtime_logs'])( + 'rejects the unmodeled %s source loudly instead of returning 0 rows', + (source) => { + // No backing table exists for these preset sources; a silent empty + // result would read as "no logs" and green-light an eval the fixture + // cannot serve. The translator throws before any SQL runs. + expect(() => + compileClickHouseLogsSql(`select id from logs where source = '${source}' limit 10`) + ).toThrow(/not modeled/i) + } + ) + it('runs the runtime (function_logs) preset source', async () => { const result = await logsDb.query<{ function_id: string; level: string; severity_text: string }>( compileClickHouseLogsSql( @@ -129,10 +141,13 @@ order by error_count desc`, }) // Route-level contract: the read-only guarantee must hold at the HTTP boundary -// where model-authored SQL arrives, not just in the translator. Dedicated DB so -// a failing guard can't poison the other tests' fixture rows. +// where model-authored SQL arrives, not just in the translator. A real +// ProjectInstance (constructor is init-free; the route only touches logsDb) in +// a real Map — the exact ProjectStore shape, no casts. Dedicated instance so a +// failing guard can't poison the other tests' fixture rows. describe('/v1/projects/:ref/analytics/endpoints/logs route', () => { - const routeDb = new PGlite() + const project = new ProjectInstance('proj', 'proj', 'test-org') + const routeDb = project.logsDb const ready = (async () => { await routeDb.exec(LOGS_BASE_SQL) for (const id of ['r1', 'r2']) { @@ -146,14 +161,10 @@ describe('/v1/projects/:ref/analytics/endpoints/logs route', () => { }) } })() - afterAll(() => routeDb.close()) - - // Minimal fake: the routes only call store.get(ref) and touch project.logsDb. - // Pick<> keeps the used surface type-checked; the single cast is confined to - // the fake project object, whose other ProjectInstance fields are never read. - const fakeProject = { logsDb: routeDb } as Partial as ProjectInstance - const store: Pick = { get: (ref) => (ref === 'proj' ? fakeProject : undefined) } - const { app } = createDebuggingRoutes(store as ProjectStore) + afterAll(() => project.close()) + + const store: ProjectStore = new Map([['proj', project]]) + const { app } = createDebuggingRoutes(store) const url = (sql: string) => `/v1/projects/proj/analytics/endpoints/logs?sql=${encodeURIComponent(sql)}` const countRows = async () => Number((await routeDb.query<{ n: string }>('select count(*) as n from function_edge_logs')).rows[0]!.n) @@ -194,4 +205,13 @@ describe('/v1/projects/:ref/analytics/endpoints/logs route', () => { expect(body.result).toEqual([]) expect(await countRows()).toBe(before) }) + + it('surfaces the unmodeled-source error through the HTTP boundary', async () => { + await ready + const res = await app.request(url("select id from logs where source = 'realtime_logs' limit 10")) + expect(res.status).toBe(200) + const body = (await res.json()) as { result: unknown[]; error?: string } + expect(body.error).toMatch(/not modeled/i) + expect(body.result).toEqual([]) + }) }) diff --git a/packages/platform-lite/test/openapi.test.ts b/packages/platform-lite/test/openapi.test.ts index 4a9c1b96..be806fb4 100644 --- a/packages/platform-lite/test/openapi.test.ts +++ b/packages/platform-lite/test/openapi.test.ts @@ -7,7 +7,9 @@ describe('OpenAPI surface', () => { const res = await app.request('/openapi.json') expect(res.status).toBe(200) - const spec = await res.json() as { paths: Record> } + const spec = await res.json() as { + paths: Record> + } expect(spec.paths['/v1/projects']).toHaveProperty('get') expect(spec.paths['/v1/projects']).toHaveProperty('post') @@ -15,7 +17,19 @@ describe('OpenAPI surface', () => { expect(spec.paths['/v1/projects/{ref}/database/migrations']).toHaveProperty('get') expect(spec.paths['/v1/projects/{ref}/database/migrations']).toHaveProperty('post') expect(spec.paths['/v1/projects/{ref}/database/migrations']).not.toHaveProperty('delete') + // Distinct operations for the legacy and ClickHouse logs endpoints — + // upstream renamed the legacy one when the no-`.all` route landed. + expect(spec.paths['/v1/projects/{ref}/analytics/endpoints/logs.all']!.get!.operationId).toBe('v1-get-project-logs-all') + expect(spec.paths['/v1/projects/{ref}/analytics/endpoints/logs']!.get!.operationId).toBe('v1-get-project-logs') expect(spec.paths['/v1/projects/{ref}/functions/{function_slug}']).toHaveProperty('get') expect(spec.paths['/v1/projects/{ref}/functions/{function_slug}/body']).toHaveProperty('get') + + // operationIds must be unique across the advertised spec (OpenAPI + // requirement; a duplicate breaks generated clients). + const ids = Object.values(spec.paths) + .flatMap((methods) => Object.values(methods)) + .map((op) => op.operationId) + .filter((id): id is string => typeof id === 'string') + expect(new Set(ids).size).toBe(ids.length) }) }) From 36e63575b7921ea71f159d2bbbf19a593fc375e3 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Thu, 23 Jul 2026 15:40:46 +0200 Subject: [PATCH 15/16] platform-lite: seed storage logs; reject unknown seed sources loudly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storage_logs was half-modeled: the table exists and the logs VIEW serves a 'storage_logs' source (which mcp's storage preset filters on), but seedLogRow silently dropped 'storage' seeds — a storage eval would read the resulting empty result as 'no logs', the exact false-green the unmodeled-source guard exists to prevent. Add seedStorageLog (base columns; the preset selects only id/timestamp/event_message) and a verbatim storage-preset test. seedLogRow's fall-through was the same bug at the seed layer: any unknown source (typo or unsupported service) silently seeded nothing. It now throws at seed time, naming the supported sources; tested. --- .../platform-lite/src/project/log-seeding.ts | 30 +++++++++++++++ .../test/clickhouse-logs.test.ts | 37 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index add6f6b4..c3066e8a 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -200,7 +200,20 @@ export async function seedLogRow(logsDb: PGlite, row: LogRow): Promise { if (normalizedSource === 'auth') { await seedAuthLog(logsDb, log) + return } + + if (normalizedSource === 'storage' || normalizedSource === 'storage_logs') { + await seedStorageLog(logsDb, log) + return + } + + // Loud failure over a silent no-op: a dropped seed surfaces later as a false + // "no logs" query result, which reads as a passing scenario. Same doctrine as + // the unmodeled-source guard in debugging.ts. + throw new Error( + `unknown log seed source '${row.source}' — expected edge-function, edge, postgres/database, auth, or storage` + ) } type NormalizedLogSeed = { @@ -321,6 +334,23 @@ async function seedAuthLog(logsDb: PGlite, log: NormalizedLogSeed): Promise { + await logsDb.query( + `INSERT INTO storage_logs + (id, identifier, timestamp, ts, event_message, message, source, level, metadata) + VALUES ($1, $2, $3, $3, $4, $4, $5, $6, $7::jsonb)`, + [ + log.id, + metadataText(log.metadata, ['identifier']), + log.ts, + log.message, + log.source, + log.level, + log.metadataJson, + ] + ) +} + function metadataText(metadata: Record, keys: string[]): string | null { for (const key of keys) { const value = metadata[key] diff --git a/packages/platform-lite/test/clickhouse-logs.test.ts b/packages/platform-lite/test/clickhouse-logs.test.ts index 23811882..d84f3d6c 100644 --- a/packages/platform-lite/test/clickhouse-logs.test.ts +++ b/packages/platform-lite/test/clickhouse-logs.test.ts @@ -28,6 +28,14 @@ for (const [id, functionId, status] of [ metadata: { function_id: functionId, status, duration_ms: 100 }, }) } +await seedLogRow(logsDb, { + id: 's1', + ts: new Date('2026-04-28T10:00:00Z'), + source: 'storage', + level: 'error', + message: 'upload failed: object too large', + metadata: { identifier: 'avatars-bucket' }, +}) afterAll(() => logsDb.close()) // verbatim from mcp getClickHouseLogQuery('edge-function') @@ -96,6 +104,35 @@ describe('compileClickHouseLogsSql + unified logs view', () => { } ) + it('runs the mcp storage preset', async () => { + // verbatim from mcp getClickHouseLogQuery('storage'), limit interpolated to 100 + const result = await logsDb.query<{ id: string; event_message: string }>( + compileClickHouseLogsSql( + `select id, timestamp, event_message +from logs +where source = 'storage_logs' +order by timestamp desc +limit 100` + ) + ) + expect(result.rows).toHaveLength(1) + expect(result.rows[0]).toMatchObject({ id: 's1', event_message: 'upload failed: object too large' }) + }) + + it('rejects an unknown seed source loudly instead of silently dropping the row', async () => { + // A silently dropped seed surfaces later as a false "no logs" query result; + // a typo'd source must fail at seed time, not read as a passing scenario. + await expect( + seedLogRow(logsDb, { + id: 'x1', + ts: new Date('2026-04-28T10:00:00Z'), + source: 'realtime', + level: 'info', + message: 'nope', + }) + ).rejects.toThrow(/unknown log seed source/i) + }) + it('runs the runtime (function_logs) preset source', async () => { const result = await logsDb.query<{ function_id: string; level: string; severity_text: string }>( compileClickHouseLogsSql( From c3dfccf747cdf7498ada249fee4d5e6f837e7e50 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Thu, 23 Jul 2026 15:47:18 +0200 Subject: [PATCH 16/16] platform-lite: expose only the unified logs stream; String-only OrZero shims Two more hosted-parity closures: - Restrict /analytics/endpoints/logs to the 'logs' relation (mattrossman's review question). ENFORCEMENT is DB-level: the route transaction runs SET LOCAL ROLE logs_reader, granted SELECT only on the logs view, so postgres name resolution denies backing-table access under any spelling (edge_logs, public.edge_logs, "edge_logs"). The FROM/JOIN regex remains as best-effort message shaping pointing the model at the source-filter idiom. The legacy logs.all route sets no role and keeps table access for its BigQuery-era dialect. The CTE read-only test now uses an INSERT CTE (passes prefix gate and regex) so the transaction stays the tested last line of defense; qualified/quoted bypass spellings are pinned in tests. - Drop the numeric *OrZero overloads: ClickHouse's toInt32OrZero family takes String only, so toInt32OrZero(42) must error here as it does hosted. They existed for the translator's implicit numeric casts, which are already gone. Negative parity test added. --- .../src/management-api/debugging.ts | 23 +++++++++ .../platform-lite/src/project/log-seeding.ts | 23 ++++++--- .../test/clickhouse-logs.test.ts | 51 ++++++++++++++++++- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/packages/platform-lite/src/management-api/debugging.ts b/packages/platform-lite/src/management-api/debugging.ts index 77e0387d..b6662454 100644 --- a/packages/platform-lite/src/management-api/debugging.ts +++ b/packages/platform-lite/src/management-api/debugging.ts @@ -68,6 +68,12 @@ export function createDebuggingRoutes(store: ProjectStore): ManagementApiRoutes const compiled = compileClickHouseLogsSql(stmt) const result = await project.logsDb.transaction(async (tx) => { await tx.exec('SET TRANSACTION READ ONLY') + // logs_reader may SELECT only the unified 'logs' view (grant in + // LOGS_BASE_SQL) — postgres name resolution denies the backing tables + // under ANY spelling (edge_logs, public.edge_logs, "edge_logs"), + // which the best-effort regex in compileClickHouseLogsSql cannot. + // SET LOCAL reverts with the transaction. + await tx.exec('SET LOCAL ROLE logs_reader') return tx.query>(compiled) }) return c.json({ result: result.rows }) @@ -137,6 +143,16 @@ export function createDebuggingRoutes(store: ProjectStore): ManagementApiRoutes * empty result would read as "no logs", green-lighting an eval the fixture * cannot actually serve. The error surfaces to the model like any other. * + * ONLY the unified 'logs' relation is queryable, matching mcp's contract + * (nothing else is described); locally the backing tables share the PGlite + * db, so a direct `from edge_logs` would succeed here while failing hosted. + * ENFORCEMENT is the logs_reader role in the route's transaction (postgres + * resolves every spelling — qualified, quoted). The FROM/JOIN regex below is + * best-effort message shaping for the common unqualified form, pointing the + * model at the source-filter idiom; bypassing it just yields the role's + * "permission denied" instead. Source names remain valid as string literals + * (where source = 'edge_logs'). + * * PROVENANCE: none of this is importable. The real dialect boundary lives in * the hosted platform's Logflare/ClickHouse backend (supabase/platform#35096, * platform-internal); mcp ships only tool descriptions, and ClickHouse @@ -149,6 +165,7 @@ export function createDebuggingRoutes(store: ProjectStore): ManagementApiRoutes * locally. A time-window-discriminating eval needs relative-time seeding. */ const UNMODELED_SOURCES = /\b(workflow_run_logs|realtime_logs)\b/i +const PHYSICAL_RELATIONS = /\b(?:from|join)\s+(edge_logs|function_edge_logs|function_logs|postgres_logs|auth_logs|storage_logs)\b/i export function compileClickHouseLogsSql(sql: string): string { const unmodeled = UNMODELED_SOURCES.exec(sql) @@ -157,6 +174,12 @@ export function compileClickHouseLogsSql(sql: string): string { `source '${unmodeled[1]}' is not modeled by platform-lite — no backing table, so results would be silently empty` ) } + const physical = PHYSICAL_RELATIONS.exec(sql) + if (physical) { + throw new Error( + `relation '${physical[1]}' is not queryable on this endpoint — query the unified 'logs' stream and filter with where source = '${physical[1]}'` + ) + } return sql .replace(/\blog_attributes\['([^']+)'\]/gi, (_m, key: string) => `(log_attributes->>'${key}')`) .replace(/\bcountIf\s*\(/gi, 'count(*) FILTER (WHERE ') diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index c3066e8a..4b3e9e14 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -148,25 +148,36 @@ CREATE VIEW logs AS SELECT id, identifier, timestamp, ts, event_message, message, level, level, 'storage_logs', metadata FROM storage_logs; +-- Enforcement of "only the unified stream is queryable" lives HERE, not in a +-- regex: the ClickHouse route's transaction runs SET LOCAL ROLE logs_reader, +-- and this role can SELECT only the logs view. The view executes with its +-- owner's privileges, so the backing tables stay readable THROUGH it while +-- direct access — however spelled (edge_logs, public.edge_logs, +-- "edge_logs") — is denied by postgres name resolution, which no regex can +-- reliably reproduce. The legacy logs.all route sets no role and keeps full +-- table access for its BigQuery-era dialect. +CREATE ROLE logs_reader; +GRANT SELECT ON logs TO logs_reader; + -- ClickHouse numeric-cast family, as models genuinely emit it in query_logs -- SQL (e.g. countIf(toInt32OrZero(log_attributes['status']) >= 400)). -- These are pg reimplementations of ClickHouse BUILTINS (also not importable --- from anywhere); signatures follow clickhouse.com/docs/sql-reference. --- ClickHouse semantics: parse the value, 0 when it isn't a number. Text and --- numeric overloads cover both raw jsonb access and the translator's casts. +-- from anywhere); signatures follow clickhouse.com/docs/sql-reference: +-- each *OrZero takes a String ONLY — no numeric overloads, so a bare +-- toInt32OrZero(42) errors here exactly as it does on hosted ClickHouse. +-- Map access always compiles to text (debugging.ts translator), which is the +-- only argument form observed from models. toString is anyelement because +-- ClickHouse's toString accepts any type. -- Grown strictly from observed model output - see debugging.ts translator note. CREATE FUNCTION toInt32OrZero(v text) RETURNS numeric AS $ch$ BEGIN RETURN coalesce(v::numeric, 0); EXCEPTION WHEN others THEN RETURN 0; END $ch$ LANGUAGE plpgsql IMMUTABLE; -CREATE FUNCTION toInt32OrZero(v numeric) RETURNS numeric AS $ch$ SELECT coalesce(v, 0) $ch$ LANGUAGE sql IMMUTABLE; CREATE FUNCTION toInt64OrZero(v text) RETURNS numeric AS $ch$ BEGIN RETURN coalesce(v::numeric, 0); EXCEPTION WHEN others THEN RETURN 0; END $ch$ LANGUAGE plpgsql IMMUTABLE; -CREATE FUNCTION toInt64OrZero(v numeric) RETURNS numeric AS $ch$ SELECT coalesce(v, 0) $ch$ LANGUAGE sql IMMUTABLE; CREATE FUNCTION toUInt32OrZero(v text) RETURNS numeric AS $ch$ BEGIN RETURN coalesce(v::numeric, 0); EXCEPTION WHEN others THEN RETURN 0; END $ch$ LANGUAGE plpgsql IMMUTABLE; -CREATE FUNCTION toUInt32OrZero(v numeric) RETURNS numeric AS $ch$ SELECT coalesce(v, 0) $ch$ LANGUAGE sql IMMUTABLE; CREATE FUNCTION toString(v anyelement) RETURNS text AS $ch$ SELECT v::text $ch$ LANGUAGE sql IMMUTABLE; ` diff --git a/packages/platform-lite/test/clickhouse-logs.test.ts b/packages/platform-lite/test/clickhouse-logs.test.ts index d84f3d6c..d59f8eb9 100644 --- a/packages/platform-lite/test/clickhouse-logs.test.ts +++ b/packages/platform-lite/test/clickhouse-logs.test.ts @@ -104,6 +104,28 @@ describe('compileClickHouseLogsSql + unified logs view', () => { } ) + it('rejects direct queries against a physical table (only the unified logs stream is exposed)', () => { + // Best-effort message shaping for the common unqualified form — the model + // gets pointed at the source-filter idiom. REAL enforcement is the + // logs_reader role in the route transaction (see the route describe for + // the qualified/quoted bypass spellings this regex cannot catch). + expect(() => compileClickHouseLogsSql('select * from edge_logs order by timestamp desc limit 5')).toThrow( + /not queryable/i + ) + // ...while the same name stays valid as a source filter. + expect(() => + compileClickHouseLogsSql("select id from logs where source = 'edge_logs' limit 5") + ).not.toThrow() + }) + + it('rejects a numeric argument to toInt32OrZero (ClickHouse takes String only)', async () => { + // ClickHouse's *OrZero builtins accept String; hosted rejects + // toInt32OrZero(42), so the shim must too — only the text overload exists. + await expect( + logsDb.query(compileClickHouseLogsSql("select countIf(toInt32OrZero(42) > 0) as n from logs where source = 'function_edge_logs'")) + ).rejects.toThrow(/does not exist/i) + }) + it('runs the mcp storage preset', async () => { // verbatim from mcp getClickHouseLogQuery('storage'), limit interpolated to 100 const result = await logsDb.query<{ id: string; event_message: string }>( @@ -217,7 +239,12 @@ describe('/v1/projects/:ref/analytics/endpoints/logs route', () => { it('rejects a data-modifying CTE and leaves fixture rows intact', async () => { await ready const before = await countRows() - const res = await app.request(url('WITH x AS (DELETE FROM function_edge_logs RETURNING *) SELECT count(*) FROM x')) + // This write passes the `with` prefix gate AND the relation guard (INSERT + // INTO is not a from/join position), so the ONLY thing stopping it is the + // read-only transaction — the last line of defense this test pins. (A CTE + // DELETE ... FROM a physical table is now caught earlier, by the + // unified-stream relation guard.) + const res = await app.request(url("WITH x AS (INSERT INTO function_edge_logs (id) VALUES ('evil') RETURNING id) SELECT * FROM x")) // Pin the status: 200 proves this went through the SQL-error path (the // read-only transaction), not the prefix gate's 400 — the gate's message // also matches /read-only/i, so without this a refactor unifying the two @@ -251,4 +278,26 @@ describe('/v1/projects/:ref/analytics/endpoints/logs route', () => { expect(body.error).toMatch(/not modeled/i) expect(body.result).toEqual([]) }) + + it.each([ + ['schema-qualified', 'select * from public.function_edge_logs limit 5'], + ['quoted', 'select * from "function_edge_logs" limit 5'], + ])('denies %s access to a backing table via the logs_reader role', async (_label, sql) => { + await ready + // These spellings bypass the best-effort regex in compileClickHouseLogsSql; + // postgres name resolution under SET LOCAL ROLE logs_reader is what + // actually enforces the unified-stream-only contract. + const res = await app.request(url(sql)) + expect(res.status).toBe(200) + const body = (await res.json()) as { result: unknown[]; error?: string } + expect(body.error).toMatch(/permission denied/i) + expect(body.result).toEqual([]) + }) + + it('still serves the unified logs view under the logs_reader role', async () => { + await ready + const res = await app.request(url("select id from logs where source = 'function_edge_logs' order by timestamp desc limit 10")) + const body = (await res.json()) as { result: unknown[] } + expect(body.result).toHaveLength(2) + }) })