Skip to content

Commit 3e26359

Browse files
os-warrenclaude
andauthored
fix(service-datasource): generate an object draft that os build accepts (#11059)
`generateObjectDraft` rendered a `*.object.ts` the platform's own validator refuses, on two independent counts: the object name carried no `${namespace}_` prefix (`defineStack()` refuses it, ADR-0028) and no `sharingModel` was emitted (`security-owd-unset` refuses it, ADR-0090 D1). The namespace is derived from the datasource's own owning package and applied through `validateObjectNamespacePrefix`, the single source of that rule. The OWD follows the shape #9666 settled for generated scaffolds: an explicit `'private'`, rendered with the reason attached. An unresolvable namespace keeps the bare name plus a loud TODO rather than inventing a prefix. Fixes #10712 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 88e32a8 commit 3e26359

4 files changed

Lines changed: 455 additions & 4 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/service-datasource": patch
3+
---
4+
5+
`os datasource introspect` now generates an object draft that `os build`
6+
accepts (#10712). The review-before-commit flow was handing the user a
7+
`*.object.ts` the platform's own validator refuses, on two independent counts:
8+
9+
- **The object name carried no `${namespace}_` prefix**, so `defineStack()`
10+
refused it outright (ADR-0028) — measured as
11+
`Object 'customers' is missing the package namespace prefix.` The prefix is
12+
now derived from the datasource's OWN owning package (`_packageId`
13+
that package's `manifest.namespace`), and applied through
14+
`validateObjectNamespacePrefix` — the same function `defineStack()` and the
15+
runtime publish gate call, so an already-prefixed remote table
16+
(`wh_accounts` under namespace `wh`) is not double-prefixed.
17+
- **No `sharingModel` was emitted**, so the author-time rule set refused it
18+
(`security-owd-unset`, ADR-0090 D1) — the same rule family #9666 hit for the
19+
`os init` template. The draft now declares `sharingModel: 'private'`
20+
explicitly, following the shape #9666 settled on for generated scaffolds:
21+
the rule's own recommended default, rendered with the reason attached.
22+
23+
When no namespace can be resolved (a datasource with no package provenance, or
24+
a package that declares none) the draft keeps the bare remote-table name and
25+
the rendered source carries a loud `TODO(namespace)`. It does not invent a
26+
prefix — mirroring `defineStack`, which skips the check entirely rather than
27+
inventing one, and avoiding an `_customers` that would trade one invalid draft
28+
for another.
29+
30+
Note the `opts.primaryKey` path still does not build: it emits
31+
`fields.<f>.primaryKey`, which is not an authorable spec field key. That is
32+
#11000, a separate open contract question, untouched here.
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The generated object draft has to survive the platform's OWN validator.
5+
*
6+
* `generateObjectDraft` renders a `*.object.ts` a human is meant to review and
7+
* commit. Two things in that output made it un-committable:
8+
*
9+
* 1. the object name carried no `${namespace}_` prefix, so `defineStack()`
10+
* refused it outright (ADR-0028) — measured on the pre-fix tree as
11+
* `Object 'customers' is missing the package namespace prefix.`;
12+
* 2. no `sharingModel` was emitted, so the author-time rule set `os build`
13+
* runs refused it (`security-owd-unset` at `objects[0].sharingModel`,
14+
* ADR-0090 D1) — the same rule family #9666 hit for the `os init` template.
15+
*
16+
* ## Why the two are pinned SEPARATELY
17+
*
18+
* A single "the draft builds now" assertion cannot say which of the two it is
19+
* measuring, and cannot fail informatively when one of them regresses on its
20+
* own. Each defect therefore gets its own case, asserting its own signal.
21+
*
22+
* ## Why `still-generates` is here at all
23+
*
24+
* Both defects above are satisfiable by emitting LESS. An implementation that
25+
* returned a minimal valid stub — right name, right OWD, no fields — would go
26+
* green on a validator-only suite while destroying the only thing this
27+
* generator exists to do. The `still-generates` block is the counterweight:
28+
* the introspected columns, the remote table name and the `external` binding
29+
* are asserted to survive the fix.
30+
*
31+
* ## The instrument
32+
*
33+
* The prefix assertion calls `validateObjectNamespacePrefix` — the same
34+
* function `defineStack()` and the runtime publish gate call — rather than
35+
* re-spelling `startsWith`. A hand-rolled check here could pass while the real
36+
* gate refuses, which is precisely the drift that produced defect (1).
37+
* The OWD assertion runs a full `ObjectSchema.safeParse`, because what is
38+
* being guarded is a VALUE's verdict, not merely a key's presence.
39+
*/
40+
41+
import { describe, it, expect } from 'vitest';
42+
import type { IntrospectedSchema } from '@objectstack/spec/contracts';
43+
import { validateObjectNamespacePrefix } from '@objectstack/spec/kernel';
44+
import { ObjectSchema } from '@objectstack/spec/data';
45+
import {
46+
ExternalDatasourceService,
47+
type DatasourceLike,
48+
type ExternalDatasourceServiceConfig,
49+
} from '../external-datasource-service.js';
50+
51+
/**
52+
* A remote schema with an ALREADY-prefixed table alongside a bare one, so the
53+
* double-prefix case (`wh_wh_accounts`) is reachable from the same fixture.
54+
*
55+
* Hand-written rather than driven off a live `SqlDriver` — deliberately, and
56+
* for a different reason than the blindness `external-introspection-seam.test.ts`
57+
* exists to close. Neither defect pinned here reads a column's PK-ness at all:
58+
* the object NAME comes from the table name and the OWD is a constant, so a
59+
* real introspection would add cost and no coverage.
60+
*
61+
* Every column is spelled `primaryKey: false` on purpose. That keeps the whole
62+
* file on the `opts.primaryKey`-unset path, where the generator emits no
63+
* `fields.<f>.primaryKey` — the key that is NOT authorable (#11000, an open
64+
* contract question in `packages/spec`, deliberately untouched here). Pinning
65+
* these two repairs on a draft that also carries #11000's key would produce
66+
* cases that cannot go green until a card this lane does not own is decided.
67+
*/
68+
function remoteSchema(): IntrospectedSchema {
69+
return {
70+
dialect: 'postgres',
71+
introspectedAt: '2026-08-22T00:00:00.000Z',
72+
tables: {
73+
'mart.customers': {
74+
name: 'mart.customers',
75+
indexes: [],
76+
columns: [
77+
{ name: 'id', type: 'text', nullable: false, primaryKey: false },
78+
{ name: 'name', type: 'varchar(255)', nullable: true, primaryKey: false },
79+
{ name: 'signed_up_at', type: 'timestamptz', nullable: true, primaryKey: false },
80+
],
81+
},
82+
'mart.wh_accounts': {
83+
name: 'mart.wh_accounts',
84+
indexes: [],
85+
columns: [{ name: 'id', type: 'text', nullable: false, primaryKey: false }],
86+
},
87+
},
88+
};
89+
}
90+
91+
/**
92+
* The service wired exactly as `plugin.ts` wires it, with the namespace
93+
* resolution the plugin injects made explicit per-test.
94+
*
95+
* `namespace: undefined` is NOT the same as omitting `getNamespace`: the first
96+
* is "a resolver ran and found nothing", the second is "no resolver at all".
97+
* Both must land on the bare name, and both are exercised below.
98+
*/
99+
function serviceWith(
100+
namespace?: string | undefined,
101+
opts: { wireResolver?: boolean } = {},
102+
): ExternalDatasourceService {
103+
const config: ExternalDatasourceServiceConfig = {
104+
introspect: async () => remoteSchema(),
105+
getDatasource: async (name): Promise<DatasourceLike> => ({ name, schemaMode: 'external' }),
106+
getObject: async () => undefined,
107+
listObjects: async () => [],
108+
...(opts.wireResolver === false ? {} : { getNamespace: () => namespace }),
109+
};
110+
return new ExternalDatasourceService(config);
111+
}
112+
113+
/** The canonical OWD values, read off the live schema rather than restated. */
114+
const CANONICAL_OWD: readonly string[] = (
115+
ObjectSchema.shape.sharingModel as unknown as { def: { innerType: { options: string[] } } }
116+
).def.innerType.options;
117+
118+
describe('defect 1 — the generated object name carries the package namespace prefix', () => {
119+
it('prefixes the derived name, and the SINGLE-SOURCE rule accepts it', async () => {
120+
const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers');
121+
122+
expect(draft.name).toBe('wh_customers');
123+
// The instrument that matters: the very function `defineStack()` calls.
124+
expect(validateObjectNamespacePrefix(draft.name, 'wh')).toBeNull();
125+
// …and the rendered file agrees with the structured definition.
126+
expect(draft.definition.name).toBe('wh_customers');
127+
expect(draft.source).toContain("name: 'wh_customers'");
128+
expect(draft.source).toContain('const wh_customers: ServiceObject = {');
129+
});
130+
131+
it('does NOT double-prefix a remote table that already carries the namespace', async () => {
132+
const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'wh_accounts');
133+
134+
expect(draft.name).toBe('wh_accounts');
135+
expect(draft.name).not.toContain('wh_wh_');
136+
expect(validateObjectNamespacePrefix(draft.name, 'wh')).toBeNull();
137+
});
138+
139+
it('keeps the LABEL derived from the short name — the prefix is addressing, not display', async () => {
140+
const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers');
141+
expect(draft.definition.label).toBe('Customers');
142+
});
143+
});
144+
145+
describe('defect 2 — the generated draft declares an explicit sharingModel', () => {
146+
it('emits the OWD the #9666 precedent settled on, and the spec accepts the VALUE', async () => {
147+
const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers');
148+
149+
expect(draft.definition.sharingModel).toBe('private');
150+
// Not just "some string": a value the canonical enum still declares. If
151+
// ADR-0090's four ever change, this reddens instead of drifting.
152+
expect(CANONICAL_OWD).toContain(draft.definition.sharingModel);
153+
154+
// A value verdict needs a full parse, not an absence-of-unknown-keys check.
155+
const parsed = ObjectSchema.safeParse(draft.definition);
156+
expect(parsed.success, JSON.stringify((parsed as { error?: unknown }).error)).toBe(true);
157+
});
158+
159+
it('renders the OWD into the committed source, with the reason attached', async () => {
160+
const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers');
161+
162+
expect(draft.source).toContain("sharingModel: 'private'");
163+
expect(draft.source).toContain('security-owd-unset');
164+
});
165+
166+
it('emits the OWD even when no namespace resolves — the two defects are independent', async () => {
167+
const draft = await serviceWith(undefined).generateObjectDraft('warehouse', 'customers');
168+
expect(draft.definition.sharingModel).toBe('private');
169+
expect(draft.source).toContain("sharingModel: 'private'");
170+
});
171+
});
172+
173+
describe('still-generates — the fix must not be satisfied by emitting a valid stub', () => {
174+
it('keeps every introspected column, its mapped type, and the review notes', async () => {
175+
const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers');
176+
const fields = draft.definition.fields as Record<string, { type: string }>;
177+
178+
expect(Object.keys(fields)).toEqual(['id', 'name', 'signed_up_at']);
179+
expect(fields.id.type).toBe('text');
180+
expect(fields.name.type).toBe('text');
181+
expect(fields.signed_up_at.type).toBe('datetime');
182+
183+
expect(draft.source).toContain("id: { type: 'text' }");
184+
expect(draft.source).toContain("signed_up_at: { type: 'datetime' }");
185+
});
186+
187+
it('keeps the external binding pointed at the REMOTE table, not the renamed object', async () => {
188+
const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers');
189+
const external = draft.definition.external as { remoteName?: string; remoteSchema?: string };
190+
191+
// The object was renamed to `wh_customers`; the remote table was not.
192+
expect(external.remoteName).toBe('customers');
193+
expect(external.remoteSchema).toBe('mart');
194+
expect(draft.definition.datasource).toBe('warehouse');
195+
expect(draft.source).toContain("remoteSchema: 'mart', remoteName: 'customers'");
196+
});
197+
});
198+
199+
describe('an absent or blank namespace must not trade one invalid draft for another', () => {
200+
/**
201+
* `_customers` is the failure mode this block exists to forbid: a name that
202+
* satisfies "has a prefix" while being neither valid nor meaningful. The
203+
* bare name is the defensible outcome — `defineStack()` skips the prefix
204+
* check entirely for a stack with no `manifest.namespace`, and deliberately
205+
* does not invent one on the author's behalf, so the draft stays committable
206+
* exactly where an unprefixed name is legal.
207+
*/
208+
it.each([
209+
['no resolver wired at all', undefined, false],
210+
['a resolver that finds nothing', undefined, true],
211+
['an empty string', '', true],
212+
['whitespace only', ' ', true],
213+
])('%s → the bare name, never a leading underscore', async (_label, ns, wired) => {
214+
const draft = await serviceWith(ns as string | undefined, {
215+
wireResolver: wired as boolean,
216+
}).generateObjectDraft('warehouse', 'customers');
217+
218+
expect(draft.name).toBe('customers');
219+
expect(draft.name.startsWith('_')).toBe(false);
220+
expect(draft.source).not.toContain('_customers:');
221+
expect(ObjectSchema.safeParse(draft.definition).success).toBe(true);
222+
});
223+
224+
it('says so loudly in the rendered file rather than failing silently', async () => {
225+
const draft = await serviceWith(undefined).generateObjectDraft('warehouse', 'customers');
226+
227+
expect(draft.source).toContain('TODO(namespace)');
228+
expect(draft.source).toContain('ADR-0028');
229+
});
230+
231+
it('carries NO namespace TODO once a namespace did resolve', async () => {
232+
const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers');
233+
expect(draft.source).not.toContain('TODO(namespace)');
234+
});
235+
});
236+
237+
describe('importObject inherits both repairs from the draft pipeline', () => {
238+
it('persists the prefixed name and the explicit OWD', async () => {
239+
const persisted: Array<{ name: string; def: Record<string, unknown> }> = [];
240+
const svc = new ExternalDatasourceService({
241+
introspect: async () => remoteSchema(),
242+
getDatasource: async (name): Promise<DatasourceLike> => ({ name, schemaMode: 'external' }),
243+
getObject: async () => undefined,
244+
listObjects: async () => [],
245+
getNamespace: () => 'wh',
246+
persistObject: async (name, def) => {
247+
persisted.push({ name, def });
248+
},
249+
});
250+
251+
const result = await svc.importObject('warehouse', 'customers');
252+
253+
expect(result.name).toBe('wh_customers');
254+
expect(persisted[0]?.name).toBe('wh_customers');
255+
expect(persisted[0]?.def.sharingModel).toBe('private');
256+
expect(ObjectSchema.safeParse(persisted[0]?.def).success).toBe(true);
257+
});
258+
});

0 commit comments

Comments
 (0)