Skip to content

Commit 8d640fa

Browse files
committed
test(service-datasource): validation skips injected anchors, and real drift stays an error
Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
1 parent b9690f1 commit 8d640fa

1 file changed

Lines changed: 126 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21788] Federated validation compares what the remote owns, and nothing the
5+
* platform injected.
6+
*
7+
* ## The defect this pins closed
8+
*
9+
* The registry injects anchors onto every object it registers — on a federated
10+
* one `organization_id`, `created_by`, `updated_by`, `owner_id` and
11+
* `owning_business_unit_id`, with no storage behind them (the remote owns the
12+
* schema). A stored federated object (saved through `PUT /meta/object` or the
13+
* external-table import, rehydrated at boot) is read back as the registry
14+
* holds it, anchors included, and `validateObjectUsing` compared every field
15+
* but `id` / `created_at` / `updated_at` with the remote table. So each anchor
16+
* came back as a `missing_column` at error severity, and the boot validation
17+
* gate aborted any datasource with the default `onMismatch: 'fail'`. Measured
18+
* on the showcase: "Object '…' does not match its remote table" listing exactly
19+
* those five columns, for an object whose remote table was intact.
20+
*
21+
* ## What each case pins
22+
*
23+
* - an object carrying the platform's own anchor definitions validates `ok`,
24+
* on the single-object read and on the per-datasource sweep the gate runs;
25+
* - the NEGATIVE CONTROL: a declared business column the remote lacks is
26+
* still a `missing_column` at error severity — the only diff, so the gate's
27+
* `fail` policy (which aborts on any measured diff) still aborts on real
28+
* drift;
29+
* - an author-declared field that only shares an anchor's NAME is the
30+
* author's (`resolveInjectedColumnProvenance` answers `'author'`) and is
31+
* still compared.
32+
*
33+
* The anchors are built with the spec's own `injectedSystemColumnDefs`, so the
34+
* fixture carries the bytes the registry injects rather than a copy of them.
35+
*/
36+
37+
import { describe, it, expect } from 'vitest';
38+
import type { IntrospectedSchema } from '@objectstack/spec/contracts';
39+
import { injectedSystemColumnDefs } from '@objectstack/spec/data';
40+
import {
41+
ExternalDatasourceService,
42+
type DatasourceLike,
43+
type ObjectLike,
44+
} from '../external-datasource-service.js';
45+
46+
const ANCHORS = ['organization_id', 'created_by', 'updated_by', 'owner_id', 'owning_business_unit_id'];
47+
48+
const remoteSchema = (): IntrospectedSchema =>
49+
({
50+
dialect: 'sqlite',
51+
tables: {
52+
customers: {
53+
name: 'customers',
54+
indexes: [],
55+
columns: [
56+
{ name: 'id', type: 'text', nullable: false, primaryKey: true },
57+
{ name: 'name', type: 'text', nullable: true, primaryKey: false },
58+
{ name: 'email', type: 'text', nullable: true, primaryKey: false },
59+
],
60+
},
61+
},
62+
}) as unknown as IntrospectedSchema;
63+
64+
/** A federated object as the import authors it. */
65+
function authored(extraFields: Record<string, Record<string, unknown>> = {}): Record<string, unknown> {
66+
return {
67+
name: 'ext_cust',
68+
label: 'Ext Cust',
69+
datasource: 'ext',
70+
external: { remoteName: 'customers' },
71+
fields: { id: { type: 'text' }, name: { type: 'text' }, email: { type: 'text' }, ...extraFields },
72+
sharingModel: 'private',
73+
};
74+
}
75+
76+
/** The same object as the registry holds it: the platform's anchors injected into `fields`. */
77+
function stored(def: Record<string, unknown>): ObjectLike {
78+
const fields = def.fields as Record<string, unknown>;
79+
return { ...def, fields: { ...injectedSystemColumnDefs(def), ...fields } } as unknown as ObjectLike;
80+
}
81+
82+
function service(objects: ObjectLike[]): ExternalDatasourceService {
83+
const ds: DatasourceLike = { name: 'ext', schemaMode: 'external' };
84+
return new ExternalDatasourceService({
85+
introspect: async () => remoteSchema(),
86+
getDatasource: async (n) => (n === ds.name ? ds : undefined),
87+
getObject: async (n) => objects.find((o) => o.name === n),
88+
listObjects: async () => objects,
89+
});
90+
}
91+
92+
describe('federated validation skips the platform\'s unprovisioned anchors (#21788)', () => {
93+
it('the fixture carries the anchors the platform injects (so the cases below are not vacuous)', () => {
94+
expect(Object.keys(stored(authored()).fields ?? {})).toEqual(expect.arrayContaining(ANCHORS));
95+
});
96+
97+
it('a stored federated object carrying the injected anchors validates ok', async () => {
98+
const svc = service([stored(authored())]);
99+
100+
expect(await svc.validateObject('ext_cust')).toEqual({ ok: true, datasource: 'ext', object: 'ext_cust', diffs: [] });
101+
expect(await svc.validateDatasource('ext')).toEqual({
102+
ok: true,
103+
results: [{ ok: true, datasource: 'ext', object: 'ext_cust', diffs: [] }],
104+
});
105+
});
106+
107+
it('negative control: a declared business column the remote lacks is still a missing_column at error severity, and the only diff', async () => {
108+
const svc = service([stored(authored({ loyalty_tier: { type: 'text' } }))]);
109+
110+
const result = await svc.validateObject('ext_cust');
111+
112+
expect(result.ok).toBe(false);
113+
expect(result.diffs).toEqual([
114+
{ kind: 'missing_column', remoteName: 'customers', column: 'loyalty_tier', severity: 'error' },
115+
]);
116+
expect((await svc.validateDatasource('ext')).ok).toBe(false);
117+
});
118+
119+
it('an author-declared field that only shares an anchor\'s name is still compared', async () => {
120+
const svc = service([stored(authored({ owner_id: { type: 'text', label: 'Account owner' } }))]);
121+
122+
expect((await svc.validateObject('ext_cust')).diffs).toEqual([
123+
{ kind: 'missing_column', remoteName: 'customers', column: 'owner_id', severity: 'error' },
124+
]);
125+
});
126+
});

0 commit comments

Comments
 (0)