Skip to content

Commit 499c5b6

Browse files
committed
wip(objectql): report a policy-denied federated datasource at info
Claude-Session: https://claude.ai/code/session_016cxs5HWHRyhQhA9EaXmnWy Co-authored-by: Claude <noreply@anthropic.com>
1 parent ba244f1 commit 499c5b6

2 files changed

Lines changed: 245 additions & 1 deletion

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25079 — `reconcileFederatedBindings`' report for a federated object whose
5+
* datasource the connect POLICY denied.
6+
*
7+
* `os migrate apply` on the stock showcase denies its `schemaMode: 'external'`
8+
* datasource on purpose (the CLI's managed-only connect policy) and then
9+
* printed, at ERROR, that the federated objects "keep their REST routes and
10+
* keep rendering in the UI, while every read … resolves to a table named after
11+
* the OBJECT". For a denied datasource that consequence is false on every
12+
* composition: the datasource layer records the verdict as kind `blocked`
13+
* (`engine.markDatasourceUnavailable`), and every read against such an object
14+
* refuses with `DatasourceUnavailableError`. So the report is judged on that
15+
* recorded verdict alone, never on what the composition serves:
16+
*
17+
* - `blocked` → ONE `info` line, outside the ERROR;
18+
* - undeclared / `failed` / a driver without `registerExternalObject` /
19+
* a registration that threw → the ERROR, unchanged, as before.
20+
*
21+
* The composition is not read by the code under test; the two shapes below
22+
* pin that a migrate-shaped and a serving-shaped context answer alike.
23+
*/
24+
25+
import { describe, it, expect } from 'vitest';
26+
import type { ServiceObject } from '@objectstack/spec/data';
27+
import { ObjectQL } from './engine.js';
28+
import { ObjectQLPlugin } from './plugin.js';
29+
30+
interface Recorded {
31+
level: 'debug' | 'info' | 'warn' | 'error';
32+
message: string;
33+
args: unknown[];
34+
}
35+
36+
function recordingLogger() {
37+
const records: Recorded[] = [];
38+
const push = (level: Recorded['level']) => (message: string, ...args: unknown[]) =>
39+
void records.push({ level, message: String(message), args });
40+
return {
41+
records,
42+
logger: { debug: push('debug'), info: push('info'), warn: push('warn'), error: push('error') },
43+
at(level: Recorded['level']) {
44+
return records.filter((r) => r.level === level);
45+
},
46+
};
47+
}
48+
49+
/** A federated object bound to `datasource` by an explicit `object.datasource`. */
50+
function federated(name: string, datasource: string): ServiceObject {
51+
return {
52+
name,
53+
label: name,
54+
datasource,
55+
fields: { id: { name: 'id', label: 'ID', type: 'text' as const } },
56+
external: { remoteName: `remote_${name}` },
57+
} as ServiceObject;
58+
}
59+
60+
/** A driver registered under `name`; `register` decides what `registerExternalObject` does. */
61+
function namedDriver(name: string, register?: 'ok' | 'throws') {
62+
return {
63+
name,
64+
supports: {},
65+
async syncSchema() { /* not reached for a federated object */ },
66+
async find() { return []; },
67+
...(register
68+
? {
69+
async registerExternalObject() {
70+
if (register === 'throws') throw new Error('remote catalog unreachable');
71+
},
72+
}
73+
: {}),
74+
};
75+
}
76+
77+
/** The two composition shapes: one that serves (an `http.server` is published) and a migrate run's (none is). */
78+
const SHAPES = {
79+
serving: (logger: unknown) => ({
80+
logger,
81+
getService: (n: string) => {
82+
if (n === 'http.server') return {};
83+
throw new Error(`[Kernel] Service '${n}' not found`);
84+
},
85+
}),
86+
migrate: (logger: unknown) => ({
87+
logger,
88+
getService: (n: string) => {
89+
throw new Error(`[Kernel] Service '${n}' not found`);
90+
},
91+
}),
92+
} as const;
93+
94+
type Setup = {
95+
objects: ServiceObject[];
96+
drivers?: unknown[];
97+
unavailable?: Array<{ name: string; kind: 'blocked' | 'failed' }>;
98+
};
99+
100+
async function reconcile(setup: Setup, shape: keyof typeof SHAPES = 'serving') {
101+
const rec = recordingLogger();
102+
const engine = new ObjectQL({ logger: rec.logger } as any);
103+
engine.registerDriver(namedDriver('default', 'ok') as any, true);
104+
for (const d of setup.drivers ?? []) engine.registerDriver(d as any);
105+
for (const obj of setup.objects) engine.registerObject(obj as any);
106+
for (const u of setup.unavailable ?? []) engine.markDatasourceUnavailable(u);
107+
const plugin = new ObjectQLPlugin();
108+
(plugin as any).ql = engine;
109+
await (plugin as any).reconcileFederatedBindings(SHAPES[shape](rec.logger));
110+
return rec;
111+
}
112+
113+
/** The info lines that report a policy denial — identified by the object they name. */
114+
const deniedLines = (rec: ReturnType<typeof recordingLogger>, object: string) =>
115+
rec.at('info').filter((r) => r.message.includes(object));
116+
117+
describe('#25079 a policy-denied (`blocked`) datasource is reported at info, never in the ERROR', () => {
118+
for (const shape of Object.keys(SHAPES) as Array<keyof typeof SHAPES>) {
119+
it(`one info line naming the denial, the datasource and the object; no ERROR (${shape}-shaped composition)`, async () => {
120+
const rec = await reconcile(
121+
{
122+
objects: [federated('fed_customer', 'ext_denied'), federated('fed_order', 'ext_denied')],
123+
unavailable: [{ name: 'ext_denied', kind: 'blocked' }],
124+
},
125+
shape,
126+
);
127+
128+
expect(rec.at('error')).toHaveLength(0);
129+
const lines = deniedLines(rec, 'fed_customer');
130+
expect(lines).toHaveLength(1);
131+
expect(lines[0].message).toContain('fed_order');
132+
expect(lines[0].message).toContain("'ext_denied'");
133+
expect(lines[0].message).toContain('denied');
134+
expect(lines[0].message).toContain('DatasourceUnavailableError');
135+
expect(lines[0].args[0]).toEqual({ denied: 2 });
136+
});
137+
}
138+
});
139+
140+
describe('#25079 controls — every other unbound class keeps the ERROR, on either composition', () => {
141+
const CONTROLS: Array<{ label: string; object: string; setup: Setup; meta: Record<string, number> }> = [
142+
{
143+
label: 'an undeclared datasource (no recorded verdict)',
144+
object: 'fed_orphan',
145+
setup: { objects: [federated('fed_orphan', 'ext_missing')] },
146+
meta: { bound: 0, unbound: 1, unsupported: 0, failed: 0 },
147+
},
148+
{
149+
label: "a FAILED connect (kind 'failed')",
150+
object: 'fed_down',
151+
setup: {
152+
objects: [federated('fed_down', 'ext_down')],
153+
unavailable: [{ name: 'ext_down', kind: 'failed' }],
154+
},
155+
meta: { bound: 0, unbound: 1, unsupported: 0, failed: 0 },
156+
},
157+
{
158+
label: 'a driver without external-object registration',
159+
object: 'fed_plain',
160+
setup: { objects: [federated('fed_plain', 'ext_plain')], drivers: [namedDriver('ext_plain')] },
161+
meta: { bound: 0, unbound: 0, unsupported: 1, failed: 0 },
162+
},
163+
{
164+
label: 'a registration that threw',
165+
object: 'fed_throws',
166+
setup: { objects: [federated('fed_throws', 'ext_throws')], drivers: [namedDriver('ext_throws', 'throws')] },
167+
meta: { bound: 0, unbound: 0, unsupported: 0, failed: 1 },
168+
},
169+
];
170+
171+
for (const c of CONTROLS) {
172+
for (const shape of Object.keys(SHAPES) as Array<keyof typeof SHAPES>) {
173+
it(`${c.label} → the ERROR (${shape}-shaped)`, async () => {
174+
const rec = await reconcile(c.setup, shape);
175+
176+
const errors = rec.at('error');
177+
expect(errors).toHaveLength(1);
178+
expect(errors[0].message).toContain(c.object);
179+
expect(errors[0].message).toContain('NOT bound to their remote table');
180+
expect(errors[0].args[1]).toEqual(c.meta);
181+
expect(deniedLines(rec, c.object)).toHaveLength(0);
182+
});
183+
}
184+
}
185+
});
186+
187+
describe('#25079 a mixed boot — info for the denied, the ERROR for the rest', () => {
188+
it('splits by verdict, and the ERROR counts exclude the denied objects', async () => {
189+
const rec = await reconcile({
190+
objects: [
191+
federated('fed_customer', 'ext_denied'),
192+
federated('fed_orphan', 'ext_missing'),
193+
federated('fed_down', 'ext_down'),
194+
],
195+
unavailable: [
196+
{ name: 'ext_denied', kind: 'blocked' },
197+
{ name: 'ext_down', kind: 'failed' },
198+
],
199+
});
200+
201+
const info = deniedLines(rec, 'fed_customer');
202+
expect(info).toHaveLength(1);
203+
expect(info[0].args[0]).toEqual({ denied: 1 });
204+
expect(info[0].message).not.toContain('fed_orphan');
205+
expect(info[0].message).not.toContain('fed_down');
206+
207+
const errors = rec.at('error');
208+
expect(errors).toHaveLength(1);
209+
expect(errors[0].message).toMatch(/^2 federated \(external\) object\(s\) are NOT bound/);
210+
expect(errors[0].message).toContain('fed_orphan');
211+
expect(errors[0].message).toContain('fed_down');
212+
expect(errors[0].message).not.toContain('fed_customer');
213+
expect(errors[0].args[1]).toEqual({ bound: 0, unbound: 2, unsupported: 0, failed: 0 });
214+
});
215+
});

‎packages/objectql/src/plugin.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1831,6 +1831,10 @@ export class ObjectQLPlugin implements Plugin {
18311831
* the outside the deployment looks healthy while declared data is simply
18321832
* not reachable — naming the objects, their datasources, the consequence
18331833
* and the fix. A boot with nothing to report says nothing.
1834+
*
1835+
* One class is reported apart, at `info` (#25079): an object whose datasource
1836+
* the connect policy denied (`listUnavailableDatasources()` kind `blocked`).
1837+
* Its reads refuse loudly, and the denial was deliberate.
18341838
*/
18351839
private async reconcileFederatedBindings(ctx: PluginContext): Promise<void> {
18361840
if (!this.ql) return;
@@ -1871,15 +1875,25 @@ export class ObjectQLPlugin implements Plugin {
18711875

18721876
let bound = 0;
18731877
const unbound: string[] = [];
1878+
const denied: string[] = [];
18741879
const unsupported: string[] = [];
18751880
const failed: string[] = [];
18761881
const datasourceOf = (name: string): string =>
18771882
this.ql?.resolveEffectiveDatasource?.(name) ?? '(default)';
1883+
// [#25079] The datasource layer's recorded verdict, read back through the
1884+
// engine: `blocked` is a connect the host policy REFUSED on purpose
1885+
// (`os migrate`'s managed-only policy refuses every `schemaMode:
1886+
// 'external'` datasource), as opposed to `failed`. Judged on the verdict
1887+
// alone, on every composition.
1888+
const blocked = new Set(
1889+
this.ql.listUnavailableDatasources().filter((d) => d.kind === 'blocked').map((d) => d.name),
1890+
);
18781891

18791892
for (const obj of federated) {
18801893
const driver: any = this.ql.getDriverForObject(obj.name);
18811894
if (!driver) {
1882-
unbound.push(`${obj.name} -> datasource '${datasourceOf(obj.name)}'`);
1895+
const datasource = datasourceOf(obj.name);
1896+
(blocked.has(datasource) ? denied : unbound).push(`${obj.name} -> datasource '${datasource}'`);
18831897
continue;
18841898
}
18851899
if (typeof driver.registerExternalObject !== 'function') {
@@ -1894,6 +1908,21 @@ export class ObjectQLPlugin implements Plugin {
18941908
}
18951909
}
18961910

1911+
// [#25079] A policy denial is a decision, not a durability loss: every read
1912+
// against such an object refuses with `DatasourceUnavailableError`, so
1913+
// nothing looks normal while it is not — functional, hence `info` per the
1914+
// AGENTS.md degradation-log-level rule, and named rather than silent
1915+
// (#7737). It stays out of the ERROR below, whose consequence ("resolves
1916+
// to a table named after the OBJECT") is false for it.
1917+
if (denied.length) {
1918+
ctx.logger.info(
1919+
`${denied.length} federated (external) object(s) left unbound because the connect policy denied their ` +
1920+
`datasource: ${denied.join(', ')}. Every read against them is refused (DatasourceUnavailableError) until ` +
1921+
`the policy allows that datasource; see its "connect denied by policy" line earlier in this boot.`,
1922+
{ denied: denied.length },
1923+
);
1924+
}
1925+
18971926
if (unbound.length === 0 && unsupported.length === 0 && failed.length === 0) {
18981927
ctx.logger.debug('Federated objects bound to their remote tables', { bound });
18991928
return;

0 commit comments

Comments
 (0)