Skip to content

Commit 106ce3d

Browse files
os-zhuangclaude
andauthored
feat(objectql): ADR-0038 L3 — post-publish runtime probes on publishPackageDrafts (#1715)
One real read per published artifact, run by the build pipeline (not the user), generalizing the seedApplied pattern: - seed_not_applied: a published seed's target object must have rows; - view_read_failed: a published view's limit-1 read must not throw; - empty_query / widget_query_failed: each dashboard widget's real dataset selection (the same queryDataset path the renderer hits) must execute and must not return empty on a populated object; - probes_unavailable (warning): widgets existed but no analytics service — silence must not read as 'probed and passed'. Findings are BuildIssue-shaped (layer 'runtime') — the same ADR-0038 verification contract the cloud L1 graph lint emits — and ride the publishPackageDrafts response as `probes`. Probes are reads only and never fail the publish they verify. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a91ec93 commit 106ce3d

4 files changed

Lines changed: 521 additions & 3 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { runBuildProbes, type ProbeEngine } from './build-probes.js';
5+
import { ObjectStackProtocolImplementation } from './protocol.js';
6+
7+
/**
8+
* ADR-0038 L3 — runtime probes. Each probe is one real read; findings are
9+
* BuildIssue-shaped (layer 'runtime'); a probe must report, never throw.
10+
*/
11+
12+
const ITEMS: Record<string, unknown> = {
13+
'seed expense_sample': { object: 'expense', records: [{ name: 'a' }] },
14+
'view expense.all': { name: 'expense.all', object: 'expense', viewKind: 'list', config: {} },
15+
'dashboard spending': {
16+
name: 'spending',
17+
widgets: [{ id: 'w1', dataset: 'expense_ds', values: ['amount'] }],
18+
},
19+
'dataset expense_ds': {
20+
name: 'expense_ds',
21+
object: 'expense',
22+
measures: [{ name: 'count', aggregate: 'count' }, { name: 'amount', aggregate: 'sum', field: 'amount' }],
23+
dimensions: [],
24+
},
25+
};
26+
27+
const getItem = async (type: string, name: string) => ITEMS[`${type} ${name}`];
28+
29+
function engineWithRows(rowsByObject: Record<string, number>): ProbeEngine {
30+
return {
31+
find: async (object: string) =>
32+
Array.from({ length: Math.min(rowsByObject[object] ?? 0, 1) }, (_, i) => ({ id: String(i) })),
33+
};
34+
}
35+
36+
describe('runBuildProbes — seeds', () => {
37+
it('flags seed_not_applied when the seeded object has no rows', async () => {
38+
const report = await runBuildProbes({
39+
engine: engineWithRows({ expense: 0 }),
40+
getItem,
41+
published: [{ type: 'seed', name: 'expense_sample' }],
42+
});
43+
expect(report.checked.seeds).toBe(1);
44+
expect(report.issues).toHaveLength(1);
45+
expect(report.issues[0]).toMatchObject({
46+
layer: 'runtime',
47+
severity: 'error',
48+
code: 'seed_not_applied',
49+
artifact: { type: 'seed', name: 'expense_sample' },
50+
ref: { type: 'object', name: 'expense' },
51+
});
52+
});
53+
54+
it('passes when rows exist', async () => {
55+
const report = await runBuildProbes({
56+
engine: engineWithRows({ expense: 3 }),
57+
getItem,
58+
published: [{ type: 'seed', name: 'expense_sample' }],
59+
});
60+
expect(report.issues).toHaveLength(0);
61+
expect(report.checked.seeds).toBe(1);
62+
});
63+
});
64+
65+
describe('runBuildProbes — views', () => {
66+
it('flags view_read_failed when the read throws; empty tables are fine', async () => {
67+
const engine: ProbeEngine = {
68+
find: async (object: string) => {
69+
if (object === 'expense') throw new Error('no such table: expense');
70+
return [];
71+
},
72+
};
73+
const report = await runBuildProbes({
74+
engine,
75+
getItem,
76+
published: [{ type: 'view', name: 'expense.all' }],
77+
});
78+
expect(report.checked.views).toBe(1);
79+
expect(report.issues).toHaveLength(1);
80+
expect(report.issues[0]).toMatchObject({
81+
code: 'view_read_failed',
82+
severity: 'error',
83+
artifact: { type: 'view', name: 'expense.all' },
84+
});
85+
expect(report.issues[0].message).toContain('no such table');
86+
87+
const ok = await runBuildProbes({
88+
engine: engineWithRows({}), // empty result, no throw
89+
getItem,
90+
published: [{ type: 'view', name: 'expense.all' }],
91+
});
92+
expect(ok.issues).toHaveLength(0);
93+
});
94+
});
95+
96+
describe('runBuildProbes — dashboard widgets', () => {
97+
it('flags empty_query when the dataset returns nothing on a populated object (incident #4)', async () => {
98+
const analytics = { queryDataset: vi.fn(async () => ({ rows: [] })) };
99+
const report = await runBuildProbes({
100+
engine: engineWithRows({ expense: 5 }),
101+
getItem,
102+
analytics,
103+
published: [{ type: 'dashboard', name: 'spending' }],
104+
});
105+
expect(analytics.queryDataset).toHaveBeenCalledOnce();
106+
// The widget's own values are used as the probe selection.
107+
expect((analytics.queryDataset.mock.calls[0][1] as any).measures).toEqual(['amount']);
108+
expect(report.checked.widgets).toBe(1);
109+
expect(report.issues).toHaveLength(1);
110+
expect(report.issues[0]).toMatchObject({
111+
code: 'empty_query',
112+
severity: 'error',
113+
artifact: { type: 'dashboard', name: 'spending' },
114+
ref: { type: 'dataset', name: 'expense_ds', member: 'w1' },
115+
});
116+
});
117+
118+
it('passes when the query returns data; empty-on-empty-object is fine', async () => {
119+
const withData = await runBuildProbes({
120+
engine: engineWithRows({ expense: 5 }),
121+
getItem,
122+
analytics: { queryDataset: async () => ({ rows: [{ amount: 42 }] }) },
123+
published: [{ type: 'dashboard', name: 'spending' }],
124+
});
125+
expect(withData.issues).toHaveLength(0);
126+
127+
const emptyObject = await runBuildProbes({
128+
engine: engineWithRows({ expense: 0 }),
129+
getItem,
130+
analytics: { queryDataset: async () => ({ rows: [] }) },
131+
published: [{ type: 'dashboard', name: 'spending' }],
132+
});
133+
expect(emptyObject.issues).toHaveLength(0); // no rows promised, none missing
134+
});
135+
136+
it('flags widget_query_failed when the query throws', async () => {
137+
const report = await runBuildProbes({
138+
engine: engineWithRows({ expense: 5 }),
139+
getItem,
140+
analytics: { queryDataset: async () => { throw new Error('RAW_SQL_UNSUPPORTED'); } },
141+
published: [{ type: 'dashboard', name: 'spending' }],
142+
});
143+
expect(report.issues).toHaveLength(1);
144+
expect(report.issues[0].code).toBe('widget_query_failed');
145+
expect(report.issues[0].message).toContain('RAW_SQL_UNSUPPORTED');
146+
});
147+
148+
it('emits ONE probes_unavailable warning when widgets exist but no analytics service does', async () => {
149+
const report = await runBuildProbes({
150+
engine: engineWithRows({ expense: 5 }),
151+
getItem,
152+
published: [{ type: 'dashboard', name: 'spending' }],
153+
});
154+
expect(report.checked.widgets).toBe(0);
155+
expect(report.issues).toHaveLength(1);
156+
expect(report.issues[0]).toMatchObject({ code: 'probes_unavailable', severity: 'warning' });
157+
});
158+
159+
it('never throws — unreadable items and engine crashes degrade to findings/skips', async () => {
160+
const report = await runBuildProbes({
161+
engine: { find: async () => { throw new Error('engine down'); } },
162+
getItem: async () => { throw new Error('metadata down'); },
163+
published: [
164+
{ type: 'seed', name: 'expense_sample' },
165+
{ type: 'view', name: 'expense.all' },
166+
{ type: 'dashboard', name: 'spending' },
167+
],
168+
});
169+
// getItem failures mean no object bindings resolve — nothing probed, nothing thrown.
170+
expect(report.checked).toEqual({ seeds: 0, views: 0, widgets: 0 });
171+
});
172+
});
173+
174+
describe('publishPackageDrafts — probes ride the response (ADR-0038 L3)', () => {
175+
it('runs probes over the published set and reports seed_not_applied', async () => {
176+
const protocol = new ObjectStackProtocolImplementation({} as never);
177+
(protocol as any).ensureOverlayIndex = async () => {};
178+
(protocol as any).getOverlayRepo = () => ({
179+
listDrafts: async () => [
180+
{ type: 'object', name: 'expense' },
181+
{ type: 'seed', name: 'expense_sample' },
182+
],
183+
get: async (_ref: any, opts: any) =>
184+
opts?.state === 'draft' ? { body: ITEMS['seed expense_sample'], hash: 'h' } : null,
185+
});
186+
vi.spyOn(protocol, 'publishMetaItem' as never).mockResolvedValue({ success: true, version: 'h', seq: 1 } as never);
187+
vi.spyOn(protocol as any, 'applySeedBodies').mockResolvedValue({ success: false, inserted: 0, updated: 0, error: 'boom' });
188+
// Probe reads: active items + an engine whose table stayed empty.
189+
(protocol as any).getMetaItem = async ({ type, name }: any) => ({ item: ITEMS[`${type} ${name}`] });
190+
(protocol as any).engine = { find: async () => [] };
191+
192+
const res = await protocol.publishPackageDrafts({ packageId: 'app.exp' });
193+
194+
expect(res.probes).toBeDefined();
195+
expect(res.probes!.checked.seeds).toBe(1);
196+
expect(res.probes!.issues.map((i) => i.code)).toEqual(['seed_not_applied']);
197+
});
198+
199+
it('omits probes when nothing was published', async () => {
200+
const protocol = new ObjectStackProtocolImplementation({} as never);
201+
(protocol as any).ensureOverlayIndex = async () => {};
202+
(protocol as any).getOverlayRepo = () => ({ listDrafts: async () => [] });
203+
const res = await protocol.publishPackageDrafts({ packageId: 'app.empty' });
204+
expect(res.probes).toBeUndefined();
205+
});
206+
});

0 commit comments

Comments
 (0)