Skip to content
41 changes: 41 additions & 0 deletions .changeset/discovery-direct-mount-route-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
"@objectstack/spec": minor
"@objectstack/metadata-protocol": minor
"@objectstack/rest": minor
"@objectstack/client": patch
---

feat(spec,metadata-protocol,rest,client): the direct-mount surfaces (`packages`, `datasources/:name/external/*`) become discoverable, and the SDK follows the advertised base (#6633)

The rest surface's `/discovery` never advertised `routes.packages` — routes
mounted but not advertised, the unstated half of ADR-0076 D12 — so the SDK's
`packages.*` always fell back to the hard-coded `/api/v1/packages`; and the
SDK's `datasources.external.*` had no discovery mechanism at all, hard-coding
`/api/v1/datasources/...` in each of its five methods. On any deployment with a
non-default API base, both families built wrong URLs (measured in #6633).
Maintainer ruling 2026-08-08 (route B, prerequisite for #6306):

- **spec** (minor, additive): `ApiRoutesSchema` declares a `datasources` key —
the base of the federation-admin family. Optional like `mcp`: absent = not
mounted.
- **metadata-protocol** (minor, additive): `getDiscovery()` advertises
`routes.packages: '/api/v1/packages'` iff the `package` service is
registered (`serviceToRouteKey` gains the mapping; the route flows through a
non-slot table because `package` is not a `CoreServiceName`). `datasources`
is deliberately NOT advertised by this builder — the mount belongs to the
REST host it cannot see (same disposition as `mcp`).
- **rest** (minor): `/discovery` advertises `routes.packages` and
`routes.datasources` as projections of the RECORDED direct mounts (#5822) —
advertisement and mounting derive from one fact, so #6306's later mount-base
move carries the advertisement along by construction. Not mounted ⇒ not
advertised. An end-to-end parity pin (`discovery-advertised-direct-mounts.
parity.test.ts`) drives the composed surface and goes red on any change that
moves only one side.
- **client** (patch, behavior fix): the five `datasources.external.*` methods
derive their base via `getRoute('datasources')` — connected clients follow
the advertised base; unconnected clients (or servers that advertise no
`datasources` key) keep building byte-identical `/api/v1/...` URLs.

No key is removed and no wire shape changes for existing deployments: servers
gain two advertised keys, and the SDK changes URLs only when a server
advertises the new keys with a non-default base.
1 change: 1 addition & 0 deletions content/docs/references/api/discovery.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,7 @@ const result = ApiRoutesSchema.parse(data);
| **storage** | `string` | optional | e.g. /api/v1/storage |
| **analytics** | `string` | optional | e.g. /api/v1/analytics |
| **packages** | `string` | optional | e.g. /api/v1/packages |
| **datasources** | `string` | optional | e.g. /api/v1/datasources — base for the datasources/:name/external/* federation-admin family; absent when no host mounts it |
| **approvals** | `string` | optional | e.g. /api/v1/approvals |
| **realtime** | `string` | optional | e.g. /api/v1/realtime |
| **notifications** | `string` | optional | e.g. /api/v1/notifications |
Expand Down
49 changes: 49 additions & 0 deletions packages/client/src/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -509,6 +509,55 @@ describe('Data actions, email, dataset query, external datasources (#3587 gap cl
);
for (let i = 1; i <= 4; i++) expect(fetchMock.mock.calls[i][1].method).toBe('POST');
});

// [#6633] The three probe cases from the issue. Case A is the pin test
// above (unconnected ⇒ the `/api/v1` convention, byte-identical to the
// pre-#6633 hardcode). B and C cover the discovery-following half.
it('[#6633] discovery WITHOUT packages/datasources keys leaves the convention untouched (case B)', async () => {
const { client, fetchMock } = createMockClient({ tables: [] });
// A server rebased to /backend/api/v9 that does not advertise the two
// direct-mount keys — exactly what a pre-#6633 rest surface answers.
(client as any)['discoveryInfo'] = {
routes: { data: '/backend/api/v9/data', metadata: '/backend/api/v9/meta', ui: '/backend/api/v9/ui' },
};
await client.packages.list();
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/packages');
await client.datasources.external.listTables('pg_main');
expect(String(fetchMock.mock.calls[1][0])).toBe(
'http://localhost:3000/api/v1/datasources/pg_main/external/tables',
);
});

it('[#6633] BOTH packages.* and all five external.* follow advertised rebased routes (case C)', async () => {
const { client, fetchMock } = createMockClient({ tables: [] });
(client as any)['discoveryInfo'] = {
routes: {
data: '/backend/api/v9/data',
metadata: '/backend/api/v9/meta',
packages: '/backend/api/v9/packages',
datasources: '/backend/api/v9/datasources',
},
};

// packages.* — the mechanism that already existed, kept following.
await client.packages.list();
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/backend/api/v9/packages');

// external.* — the half that ignored discovery entirely before #6633.
// All five in ONE case so a half-fix (some methods still hard-coded)
// cannot stay green.
const base = 'http://localhost:3000/backend/api/v9/datasources/pg_main/external';
await client.datasources.external.listTables('pg_main', { schema: 'public' });
expect(String(fetchMock.mock.calls[1][0])).toBe(`${base}/tables?schema=public`);
await client.datasources.external.draft('pg_main', 'customers');
expect(String(fetchMock.mock.calls[2][0])).toBe(`${base}/tables/customers/draft`);
await client.datasources.external.import('pg_main', 'customers');
expect(String(fetchMock.mock.calls[3][0])).toBe(`${base}/tables/customers/import`);
await client.datasources.external.refreshCatalog('pg_main');
expect(String(fetchMock.mock.calls[4][0])).toBe(`${base}/refresh-catalog`);
await client.datasources.external.validate('pg_main');
expect(String(fetchMock.mock.calls[5][0])).toBe(`${base}/validate`);
});
});

describe('Approvals namespace (ADR-0019)', () => {
Expand Down
30 changes: 25 additions & 5 deletions packages/client/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1060,22 +1060,31 @@ export class ObjectStackClient {
* catalog and importing tables as federated objects. 503
* [external_service_unavailable] without the `external-datasource`
* service. (#3587 gap closure)
*
* [#6633] The family base comes from `getRoute('datasources')`: a connected
* client follows the server's advertised `routes.datasources` (the REST
* discovery endpoint derives it from its recorded mounts, ADR-0076 D12);
* an unconnected client — or one talking to a server that advertises no
* `datasources` key — falls back to the `/api/v1/datasources` convention,
* byte-identical to the pre-#6633 hardcode.
*/
datasources = {
external: {
/** List remote tables on a datasource, optionally by `schema`. */
listTables: async (name: string, opts?: { schema?: string }): Promise<any> => {
const qs = opts?.schema ? `?schema=${encodeURIComponent(opts.schema)}` : '';
const route = this.getRoute('datasources');
const res = await this.fetch(
`${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/tables${qs}`,
`${this.baseUrl}${route}/${encodeURIComponent(name)}/external/tables${qs}`,
);
return this.unwrapResponse<any>(res);
},

/** Generate an Object draft (structured + `*.object.ts` source) from a remote table. */
draft: async (name: string, remoteTable: string, opts?: Record<string, any>): Promise<any> => {
const route = this.getRoute('datasources');
const res = await this.fetch(
`${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/tables/${encodeURIComponent(remoteTable)}/draft`,
`${this.baseUrl}${route}/${encodeURIComponent(name)}/external/tables/${encodeURIComponent(remoteTable)}/draft`,
{ method: 'POST', body: JSON.stringify(opts ?? {}) },
);
return this.unwrapResponse<any>(res);
Expand All@@ -1086,26 +1095,29 @@ export class ObjectStackClient {
* Object"). 400 [external_import_error] when refused.
*/
import: async (name: string, remoteTable: string, opts?: Record<string, any>): Promise<any> => {
const route = this.getRoute('datasources');
const res = await this.fetch(
`${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/tables/${encodeURIComponent(remoteTable)}/import`,
`${this.baseUrl}${route}/${encodeURIComponent(name)}/external/tables/${encodeURIComponent(remoteTable)}/import`,
{ method: 'POST', body: JSON.stringify(opts ?? {}) },
);
return this.unwrapResponse<any>(res);
},

/** Refresh and return the cached remote-catalog snapshot. */
refreshCatalog: async (name: string): Promise<any> => {
const route = this.getRoute('datasources');
const res = await this.fetch(
`${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/refresh-catalog`,
`${this.baseUrl}${route}/${encodeURIComponent(name)}/external/refresh-catalog`,
{ method: 'POST', body: JSON.stringify({}) },
);
return this.unwrapResponse<any>(res);
},

/** Validate this datasource's federated objects against the remote schema. */
validate: async (name: string): Promise<any> => {
const route = this.getRoute('datasources');
const res = await this.fetch(
`${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/validate`,
`${this.baseUrl}${route}/${encodeURIComponent(name)}/external/validate`,
{ method: 'POST', body: JSON.stringify({}) },
);
return this.unwrapResponse<any>(res);
Expand DownExpand Up@@ -4792,6 +4804,14 @@ export class ObjectStackClient {
// which suits `mcp` exactly: `/mcp` is mounted bare, so even a
// project-scoped discovery response advertises the unscoped path.
mcp: '/api/v1/mcp',
// [#6633] `datasources` became a declared `ApiRoutes` key (the base of
// the `datasources/:name/external/*` federation-admin family), and this
// map is TOTAL over declared keys by design. `/api/v1/datasources` is
// not a guess: it is where `@objectstack/rest` mounts the family today,
// so an unconnected client builds byte-identical URLs to the pre-#6633
// hardcode — the fallback agrees with the mount instead of competing
// with it.
datasources: '/api/v1/datasources',
};

return routeMap[type] || `/api/v1/${type}`;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,6 +124,41 @@ describe('[#4828] getDiscovery() conforms to DiscoverySchema', () => {
expect(declaredRouteKeys().has('mcp')).toBe(true);
});

// ═════════════════════════════════════════════════════════════════════════
// [#6633] The direct-mount surface keys: `packages` flows, `datasources`
// deliberately does not
// ═════════════════════════════════════════════════════════════════════════
describe('[#6633] direct-mount surface keys', () => {
it('advertises `routes.packages` iff the `package` service is registered', async () => {
// Registered — the same predicate that gates the @objectstack/rest
// direct-mount registrar (`direct-mount-composition.ts`), so on the host
// that serves this builder's shape, advertised ⇔ mounted.
const withPackage: any = await makeImpl(new Map([['package', {}]])).getDiscovery();
expect(withPackage.routes.packages).toBe('/api/v1/packages');

// Absent — nothing mounts the surface for this boot, so the key is
// absent, never a promise of a 404 (ADR-0076 D12).
const without: any = await makeImpl().getDiscovery();
expect(Object.prototype.hasOwnProperty.call(without.routes, 'packages')).toBe(false);
});

it('does NOT advertise `datasources` — this builder knows nothing about the federation mount', async () => {
// Same disposition as `mcp` above: the `datasources/:name/external/*`
// family is mounted by the REST host (unconditionally, 503-degrading),
// which this builder cannot see — and the runtime dispatcher serves no
// /datasources domain at all. Even a registered `external-datasource`
// service says nothing about an HTTP mount, so advertising here would be
// the advertise-the-unmounted half of D12. The REST discovery endpoint
// advertises it from its recorded direct mounts.
const discovery: any = await makeImpl(
new Map([['external-datasource', {}]]),
).getDiscovery();

expect(Object.prototype.hasOwnProperty.call(discovery.routes, 'datasources')).toBe(false);
expect(declaredRouteKeys().has('datasources')).toBe(true);
});
});

// ═════════════════════════════════════════════════════════════════════════
// [#5672] Fullness: the vocabulary, whole, from every producer
// ═════════════════════════════════════════════════════════════════════════
Expand Down
38 changes: 38 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2793,6 +2793,34 @@ export class ObjectStackProtocolImplementation implements
ai: 'ai',
i18n: 'i18n',
'file-storage': 'storage',
// [#6633] The package-management surface. `package` is NOT a
// CoreServiceName slot, so it must not enter SERVICE_CONFIG — a
// non-slot row there is the shape of the retired `graphql` defect,
// and it would also fabricate a `services` availability entry whose
// remedy line lies. Its route flows through the
// NON_SLOT_SERVICE_ROUTES loop below instead: same gate
// (registered service), same mapping table, one hop over.
package: 'packages',
};

// [#6633] Routes advertised for registered services that are not
// CoreServiceName slots. Advertised iff the service is registered —
// the same convention every SERVICE_CONFIG row uses, and for `package`
// it is exactly the predicate that decides the mount on both real host
// types: the @objectstack/rest direct-mount registrar is gated on this
// same service (`direct-mount-composition.ts`), and the runtime
// dispatcher — whose `/packages` domain is unconditional — answers
// discovery from its own `getDiscoveryInfo()`, never from this
// builder.
//
// `datasources` is deliberately NOT here (same reasoning as `mcp`,
// #5679): the federation mount belongs to the REST host, which this
// builder cannot see, and the runtime dispatcher serves no
// `/datasources` domain at all — advertising it from here would be the
// advertise-the-unmounted half of ADR-0076 D12. The REST discovery
// endpoint advertises it from its recorded direct mounts.
const NON_SLOT_SERVICE_ROUTES: Record<string, string> = {
package: '/api/v1/packages',
};

const optionalRoutes: Partial<ApiRoutes> = {};
Expand All@@ -2809,6 +2837,16 @@ export class ObjectStackProtocolImplementation implements
}
}

// [#6633] Same flow for the non-slot routed services declared above.
for (const [serviceName, route] of Object.entries(NON_SLOT_SERVICE_ROUTES)) {
if (registeredServices.has(serviceName)) {
const routeKey = serviceToRouteKey[serviceName];
if (routeKey) {
optionalRoutes[routeKey] = route;
}
}
}

const routes: ApiRoutes = {
data: '/api/v1/data',
metadata: '/api/v1/meta',
Expand Down
Loading
Loading