Skip to content

Commit 9327cd8

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(analytics): dataset widgets degrade to empty when backing object/table is absent (#1851)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2a47a28 commit 9327cd8

2 files changed

Lines changed: 59 additions & 1 deletion

File tree

packages/services/service-analytics/src/__tests__/query-dataset.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,25 @@ describe('AnalyticsService.queryDataset', () => {
5353
).rejects.toThrow(/not declared in the dataset's `include`/);
5454
});
5555

56+
it('degrades to an empty result when the backing table is missing (no such table)', async () => {
57+
const svc = new AnalyticsService({
58+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
59+
executeRawSql: async () => { throw new Error('SELECT COUNT(*) FROM "opportunity" - no such table: opportunity'); },
60+
});
61+
const result = await svc.queryDataset(dataset, { dimensions: ['region'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext);
62+
expect(result).toEqual({ rows: [], fields: [], totals: [] });
63+
});
64+
65+
it('still throws on a non-missing-source error (real query bugs surface)', async () => {
66+
const svc = new AnalyticsService({
67+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
68+
executeRawSql: async () => { throw new Error('syntax error near "FROM"'); },
69+
});
70+
await expect(
71+
svc.queryDataset(dataset, { dimensions: ['region'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext),
72+
).rejects.toThrow(/syntax error/);
73+
});
74+
5675
it('pre-registered datasets (config.datasets) are compiled at construction', () => {
5776
const svc = new AnalyticsService({
5877
datasets: [dataset],

packages/services/service-analytics/src/analytics-service.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,27 @@ import { DatasetExecutor } from './dataset-executor.js';
2121
import { resolveDimensionLabels, type DimensionLabelDeps } from './dimension-labels.js';
2222
import { evaluateAnalyticsQueryOverRows } from './preview-evaluator.js';
2323

24+
/**
25+
* Detect the "backing object/table isn't present in this kernel" class of
26+
* error so a dataset query can degrade to an empty result instead of failing
27+
* the widget with a 500. Matches the missing-relation signatures across the
28+
* drivers ObjectStack runs on (sqlite/libsql, postgres, mysql) plus the
29+
* framework's own unknown-object signal. Deliberately scoped to MISSING SOURCE
30+
* (table/object/relation) — not column/syntax errors, which stay hard failures
31+
* so real query bugs still surface.
32+
*/
33+
function isMissingSourceError(err: unknown): boolean {
34+
const msg = String((err as { message?: unknown })?.message ?? err ?? '').toLowerCase();
35+
return (
36+
msg.includes('no such table') || // sqlite / libsql
37+
(msg.includes('relation') && msg.includes('does not exist')) || // postgres
38+
msg.includes("doesn't exist") || // mysql ("table ... doesn't exist")
39+
msg.includes('not registered') || // framework: object not in registry
40+
msg.includes('unknown object') ||
41+
msg.includes('is not a registered object')
42+
);
43+
}
44+
2445
/**
2546
* Configuration for AnalyticsService.
2647
*/
@@ -390,7 +411,25 @@ export class AnalyticsService implements IAnalyticsService {
390411
}
391412
}
392413

393-
const result = await new DatasetExecutor(this).execute(compiled, selection, context);
414+
// Graceful degradation: a dashboard/report widget whose backing object or
415+
// table is not present in this kernel (e.g. a platform dashboard like
416+
// System Overview that charts `sys_audit_log`, opened in an environment
417+
// that never mounted the audit object) must render as "no data" — NOT
418+
// crash the widget with a 500. Datasets were the one read surface that
419+
// hard-failed on a missing source.
420+
let result: AnalyticsResult;
421+
try {
422+
result = await new DatasetExecutor(this).execute(compiled, selection, context);
423+
} catch (err) {
424+
if (isMissingSourceError(err)) {
425+
this.logger.warn(
426+
`[Analytics] dataset "${dataset.name}" backing object "${dataset.object}" is unavailable ` +
427+
`(${String((err as Error)?.message ?? err)}); returning an empty result instead of failing the widget`,
428+
);
429+
return { rows: [], fields: [], totals: [] };
430+
}
431+
throw err;
432+
}
394433

395434
// ADR-0021 — resolve grouped dimension values to human display labels
396435
// (select option label, lookup related-record name). Charts render the

0 commit comments

Comments
 (0)