Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
41 changes: 40 additions & 1 deletion packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
Expand Down