From c03a10c9b45962eacf90f9a4c41806ccc784fe37 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:58:19 +0500 Subject: [PATCH] fix(analytics): dataset widgets degrade to empty when backing object/table is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dashboard/report dataset whose backing object isn't present in the kernel hard-failed the widget with a 500. Concretely: the platform System Overview dashboard (shipped by plugin-auth) charts `sys_audit_log` via the `sys_audit_log_metrics` dataset; opened in an environment that never mounted the audit object, every audit widget 500'd ("no such table: sys_audit_log"). Wrap the dataset execution in `queryDataset`: on a MISSING-SOURCE error (table/object/relation absent — matched across sqlite/libsql, postgres, mysql, and the framework's unknown-object signal) log a warning and return an empty result, so the widget renders "no data" instead of crashing. Column/ syntax errors still throw so real query bugs surface. The list grid and detail surfaces already tolerate absent data; datasets were the gap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/query-dataset.test.ts | 19 +++++++++ .../src/analytics-service.ts | 41 ++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts index b21fd3fbe5..643f6b3e15 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts @@ -53,6 +53,25 @@ describe('AnalyticsService.queryDataset', () => { ).rejects.toThrow(/not declared in the dataset's `include`/); }); + it('degrades to an empty result when the backing table is missing (no such table)', async () => { + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => { throw new Error('SELECT COUNT(*) FROM "opportunity" - no such table: opportunity'); }, + }); + const result = await svc.queryDataset(dataset, { dimensions: ['region'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext); + expect(result).toEqual({ rows: [], fields: [], totals: [] }); + }); + + it('still throws on a non-missing-source error (real query bugs surface)', async () => { + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => { throw new Error('syntax error near "FROM"'); }, + }); + await expect( + svc.queryDataset(dataset, { dimensions: ['region'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext), + ).rejects.toThrow(/syntax error/); + }); + it('pre-registered datasets (config.datasets) are compiled at construction', () => { const svc = new AnalyticsService({ datasets: [dataset], diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 3722ed2031..e6748f175e 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -21,6 +21,27 @@ import { DatasetExecutor } from './dataset-executor.js'; import { resolveDimensionLabels, type DimensionLabelDeps } from './dimension-labels.js'; import { evaluateAnalyticsQueryOverRows } from './preview-evaluator.js'; +/** + * Detect the "backing object/table isn't present in this kernel" class of + * error so a dataset query can degrade to an empty result instead of failing + * the widget with a 500. Matches the missing-relation signatures across the + * drivers ObjectStack runs on (sqlite/libsql, postgres, mysql) plus the + * framework's own unknown-object signal. Deliberately scoped to MISSING SOURCE + * (table/object/relation) — not column/syntax errors, which stay hard failures + * so real query bugs still surface. + */ +function isMissingSourceError(err: unknown): boolean { + const msg = String((err as { message?: unknown })?.message ?? err ?? '').toLowerCase(); + return ( + msg.includes('no such table') || // sqlite / libsql + (msg.includes('relation') && msg.includes('does not exist')) || // postgres + msg.includes("doesn't exist") || // mysql ("table ... doesn't exist") + msg.includes('not registered') || // framework: object not in registry + msg.includes('unknown object') || + msg.includes('is not a registered object') + ); +} + /** * Configuration for AnalyticsService. */ @@ -390,7 +411,25 @@ export class AnalyticsService implements IAnalyticsService { } } - const result = await new DatasetExecutor(this).execute(compiled, selection, context); + // Graceful degradation: a dashboard/report widget whose backing object or + // table is not present in this kernel (e.g. a platform dashboard like + // System Overview that charts `sys_audit_log`, opened in an environment + // that never mounted the audit object) must render as "no data" — NOT + // crash the widget with a 500. Datasets were the one read surface that + // hard-failed on a missing source. + let result: AnalyticsResult; + try { + result = await new DatasetExecutor(this).execute(compiled, selection, context); + } catch (err) { + if (isMissingSourceError(err)) { + this.logger.warn( + `[Analytics] dataset "${dataset.name}" backing object "${dataset.object}" is unavailable ` + + `(${String((err as Error)?.message ?? err)}); returning an empty result instead of failing the widget`, + ); + return { rows: [], fields: [], totals: [] }; + } + throw err; + } // ADR-0021 — resolve grouped dimension values to human display labels // (select option label, lookup related-record name). Charts render the