Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/discovery-email-route-key.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
"@objectstack/spec": minor
"@objectstack/rest": minor
"@objectstack/client": patch
---

feat(spec,rest,client): the email surface becomes discoverable and the SDK follows the advertised base; the scoped client derives its prefix from discovery (#6714)

`@objectstack/client` 的 `email.send` 硬编码 `${baseUrl}/api/v1/email/send`,而服务端
`registerEmailEndpoints` 挂在 `getApiBasePath()` 下、**已经跟随 `apiPath`** —— 设了
`apiPath` 的部署上这是**现活 404**,不是潜伏项。实测(`apiPath: '/backend/api/v9'`
启动,录制挂载表):email 面只有 `POST /backend/api/v9/email/send` 一条,
`POST /api/v1/email/send` 在表中**不存在**。`ScopedProjectClient.scope()` 同样硬编码
`/api/v1/environments/...`,scoped 面全部 `meta` / `data` / `batch` / `packages` /
`automation` URL 由它拼出;同一启动下 83 条 scoped 路由全在
`/backend/api/v9/environments/:environmentId/...`,`/api/v1/environments/` 前缀零挂载。

按维护者裁定(2026-08-08)复刻 #6633 / PR #6712 的四车道模式:

- **spec**(minor,纯增量):`ApiRoutesSchema` 声明 `email` 键 —— `POST {email}/send`
的挂载 base。`optional` 同 `datasources`:缺席 = 未挂载。
- **rest**(minor):`/discovery` 把 `routes.email` 作为**已录制挂载**的投影通告
(RouteManager 表中 `registerEmailEndpoints` 写入的那一行,mounted ⇒ advertised,
不二次计算)—— 挂载随 `apiPath` 移动时,通告按构造随行。未挂载 ⇒ 不通告。
奇偶钉(`discovery-advertised-direct-mounts.parity.test.ts`)扩展覆盖 email:
通告值 + `/send` 必须在同一张挂载表里解析得到,单侧移动即红。
- **client**(patch,行为修复):`email.send` 走 `getRoute('email')`;
`ScopedProjectClient.scope()` 从通告的 `routes.data` base 推导 scoped 前缀。
未连接、或服务端未通告 / 不可推导时,回退 URL 与旧硬编码**逐字节一致**。

面 3 为何用 `routes.data` 而不是 `scoping` 块:实测 discovery 的 `scoping` 只有
`enabled` / `resolution` / `scoped` / `environmentId` 四个键,**全是姿态、无路径**,
无法推导 base;`routes.data` 由 rest 通告为 `{realBase}{crud.dataPrefix}`,是唯一可
推导的来源。`dataPrefix` 被改成非 `/data` 时推导主动放弃、回退惯例(不做宽松再解析)。

`cloud.environments.*` 面(约 30 处)经测量**未改**:本仓无任何宿主挂载 `/cloud/*` ——
`@objectstack/rest` 的路由台账(`rest-route-ledger.ts`,由双向 conformance 门禁保证
穷尽)cloud 行数为 **0**;runtime dispatcher 无 cloud domain(无 `handleCloud`、无
`domains/cloud.ts`),且显式把 `/cloud` 列为他宿主的控制面(`skipPaths`)。而 `apiPath`
是 `@objectstack/rest` 独有配置项 —— 该面不随 `apiPath` 移动,按裁定「不随则不收敛」
保持原样。
1 change: 1 addition & 0 deletions content/docs/references/api/discovery.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@ const result = ApiRoutesSchema.parse(data);
| **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 |
| **email** | `string` | optional | e.g. /api/v1/email — base for the email/send endpoint; 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
130 changes: 130 additions & 0 deletions packages/client/src/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -558,6 +558,36 @@ describe('Data actions, email, dataset query, external datasources (#3587 gap cl
await client.datasources.external.validate('pg_main');
expect(String(fetchMock.mock.calls[5][0])).toBe(`${base}/validate`);
});

// [#6714] Face 1: `email.send` joins `getRoute()`. Case A is the pin test
// above ('email.send pins POST /email/send') — unconnected ⇒ the
// `/api/v1/email/send` convention, byte-identical to the pre-#6714
// hardcode. B and C cover the discovery-following half.
it('[#6714] discovery WITHOUT an email key leaves the convention untouched (case B)', async () => {
const { client, fetchMock } = createMockClient({ status: 'sent' });
// A server rebased to /backend/api/v9 that does not advertise the
// email key — exactly what a pre-#6714 rest surface answers.
(client as any)['discoveryInfo'] = {
routes: { data: '/backend/api/v9/data', metadata: '/backend/api/v9/meta' },
};
await client.email.send({ to: 'a@example.com', subject: 'Hello', text: 'hi' });
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/email/send');
});

it('[#6714] email.send follows the advertised rebased routes.email (case C)', async () => {
const { client, fetchMock } = createMockClient({ status: 'sent' });
(client as any)['discoveryInfo'] = {
routes: {
data: '/backend/api/v9/data',
metadata: '/backend/api/v9/meta',
email: '/backend/api/v9/email',
},
};
await client.email.send({ to: 'a@example.com', subject: 'Hello', text: 'hi' });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('http://localhost:3000/backend/api/v9/email/send');
expect(init.method).toBe('POST');
});
});

describe('Approvals namespace (ADR-0019)', () => {
Expand DownExpand Up@@ -1609,6 +1639,106 @@ describe('ScopedProjectClient', () => {
expect(scoped.getProjectId()).toBe('00000000-0000-0000-0000-000000000001');
});

// [#6714 face 3] The scoped prefix derives from the advertised
// `routes.data` base (the `scoping` block carries posture only — no path
// — so `routes.data` is the one derivable source). Case A = the pin tests
// above: unconnected ⇒ byte-identical `/api/v1/environments/...`. B and C
// below cover the derivation half.
it('[#6714] scoped prefix follows the advertised base of routes.data (case C) — every namespace', async () => {
const { client, fetchMock } = createMockClient({ ok: true, types: [] });
(client as any)['discoveryInfo'] = {
routes: { data: '/backend/api/v9/data', metadata: '/backend/api/v9/meta' },
};
const scoped = client.project('proj-123');
const base = 'http://localhost:3000/backend/api/v9/environments/proj-123';

// All namespaces build off ONE scope() — drive one method from each so
// a half-fix (some namespaces re-hardcoding the prefix) cannot stay
// green.
await scoped.meta.getTypes();
expect(String(fetchMock.mock.calls[0][0])).toBe(`${base}/meta`);
await scoped.data.get('task', 't1');
expect(String(fetchMock.mock.calls[1][0])).toBe(`${base}/data/task/t1`);
await scoped.packages.list();
expect(String(fetchMock.mock.calls[2][0])).toBe(`${base}/packages`);
await scoped.automation.getFlow('flow-1');
expect(String(fetchMock.mock.calls[3][0])).toBe(`${base}/automation/flow-1`);
await scoped.data.batchTransaction([{ operation: 'create', object: 'task', data: {} } as any]);
expect(String(fetchMock.mock.calls[4][0])).toBe(`${base}/batch`);
});

it('[#6714] a custom dataPrefix makes the base underivable — the convention holds, byte-identical (case B)', async () => {
const { client, fetchMock } = createMockClient({ types: [] });
// routes.data does not end with the conventional `/data`, so the base
// cannot be derived honestly; the client must NOT guess (contract-first
// — no lenient re-parsing) and falls back to the convention,
// byte-identical to the pre-#6714 behavior.
(client as any)['discoveryInfo'] = {
routes: { data: '/backend/api/v9/records', metadata: '/backend/api/v9/meta' },
};
await client.project('proj-123').meta.getTypes();
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/environments/proj-123/meta',
);
});

it('[#6714] a scoped discovery response strips its OWN /environments/{id} segment before re-scoping', async () => {
const { client, fetchMock } = createMockClient({ types: [] });
// Discovery answered from the environment-scoped mount: routes.data is
// `{base}/environments/{served-id}/data` and scoping says so. The
// derived base must be the UNSCOPED one, so a scoped client for a
// DIFFERENT environment does not stack two scope segments.
(client as any)['discoveryInfo'] = {
routes: {
data: '/backend/api/v9/environments/env-served/data',
metadata: '/backend/api/v9/environments/env-served/meta',
},
scoping: { enabled: true, resolution: 'auto', scoped: true, environmentId: 'env-served' },
};
await client.project('proj-other').meta.getTypes();
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/backend/api/v9/environments/proj-other/meta',
);
});

it('[#6714] a scoped response whose environmentId the host never resolved still strips ONE scope segment', async () => {
const { client, fetchMock } = createMockClient({ types: [] });
// `rest-server.ts` advertises `scoping.environmentId` as
// `req.params?.environmentId` — a host that did not populate the route
// param answers `scoped: true` with NO id, and `routes.data` keeps the
// literal `:environmentId`. Stripping on the strength of `scoped` alone
// is sound (a scoped base ends with that segment by construction) and
// is what keeps `scope()` from stacking two scope segments.
(client as any)['discoveryInfo'] = {
routes: {
data: '/backend/api/v9/environments/:environmentId/data',
metadata: '/backend/api/v9/environments/:environmentId/meta',
},
scoping: { enabled: true, resolution: 'auto', scoped: true },
};
await client.project('proj-other').meta.getTypes();
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/backend/api/v9/environments/proj-other/meta',
);
});

it('[#6714] scoped:true with no recognisable scope segment DECLINES — convention, never a doubled prefix', async () => {
const { client, fetchMock } = createMockClient({ types: [] });
// A base the derivation does not understand: `scoped` claims the
// response came off the scoped mount, but `routes.data` carries no
// `/environments/{seg}` to remove. Returning it unchanged would build
// `…/tenants/t1/environments/proj-other/meta` — a URL neither mount
// serves, i.e. strictly worse than the hardcode. Decline instead.
(client as any)['discoveryInfo'] = {
routes: { data: '/backend/api/v9/tenants/t1/data' },
scoping: { enabled: true, resolution: 'auto', scoped: true },
};
await client.project('proj-other').meta.getTypes();
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/environments/proj-other/meta',
);
});

it('prefixes the screen-flow automation.resume / getScreen calls', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
const scoped = client.project('proj-123');
Expand Down
88 changes: 84 additions & 4 deletions packages/client/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1046,7 +1046,15 @@ export class ObjectStackClient {
sentBy?: string;
[k: string]: any;
}): Promise<any> => {
const res = await this.fetch(`${this.baseUrl}/api/v1/email/send`, {
// [#6714] The base comes from `getRoute('email')`: a connected client
// follows the server's advertised `routes.email` (the REST discovery
// endpoint projects it from its recorded route registrations — the
// mount follows `apiPath`, so the old hard-coded `/api/v1` was a live
// 404 on any `apiPath` deployment); an unconnected client — or one
// talking to a server that advertises no `email` key — falls back to
// the `/api/v1/email` convention, byte-identical to the old hardcode.
const route = this.getRoute('email');
const res = await this.fetch(`${this.baseUrl}${route}/send`, {
method: 'POST',
body: JSON.stringify(message),
});
Expand DownExpand Up@@ -1733,6 +1741,59 @@ export class ObjectStackClient {
/** @internal */
_isFilterAST(v: unknown): boolean { return this.isFilterAST(v); }

/**
* @internal The unscoped API base this client's server actually serves,
* derived from the advertised routes (#6714 face 3).
*
* There is no discovery key that carries the raw API base itself, and the
* `scoping` block carries posture only (`enabled` / `resolution` / `scoped`
* / `environmentId` — no path), so the one derivable source is
* `routes.data`: the REST discovery endpoint advertises it as
* `{realBase}{dataPrefix}` with `dataPrefix` defaulting to `/data`. This
* derivation strips that conventional suffix; when the deployment customises
* `dataPrefix` away from `/data` the derivation declines and the caller
* falls back to the `/api/v1` convention — exactly today's behavior, so the
* change is strictly "follow the advertised base when it is derivable".
*
* When the discovery response was served from the environment-scoped mount
* (`scoping.scoped`), `routes.data` is `{base}/environments/{id}/data`; the
* scope segment must come off so the returned base is the UNSCOPED one (the
* scoped client re-appends its own environment id, which need not be the one
* discovery resolved). `scoping.environmentId` names that segment when the
* server resolved one — but rest advertises it as `req.params?.environmentId`,
* so a host that did not populate the route param answers `scoped: true` with
* NO id and a `routes.data` still carrying the literal `:environmentId`. That
* case strips one trailing `/environments/{segment}` on the strength of
* `scoped` alone, which is sound because a scoped response's base ends with
* that segment by construction. If NEITHER shape is present the advertised
* base is not one this derivation understands, so it declines rather than
* return a base of unknown shape — handing back a still-scoped base would make
* `scope()` build a doubled `/environments/…/environments/…` URL, i.e.
* strictly WORSE than the hardcode this replaces. Declining is always
* byte-identical to today.
*/
_apiBase(): string {
const data = this.discoveryInfo?.routes?.data;
if (typeof data === 'string' && data.endsWith('/data')) {
let base = data.slice(0, -'/data'.length);
const scoping = this.discoveryInfo?.scoping;
if (scoping?.scoped) {
const advertised = typeof scoping.environmentId === 'string' && scoping.environmentId
? `/environments/${scoping.environmentId}`
: undefined;
if (advertised && base.endsWith(advertised)) {
base = base.slice(0, -advertised.length);
} else {
const stripped = base.replace(/\/environments\/[^/]+$/, '');
if (stripped === base) return '/api/v1';
base = stripped;
}
}
if (base) return base;
}
return '/api/v1';
}

/**
* Organization Services
*
Expand DownExpand Up@@ -4812,8 +4873,16 @@ export class ObjectStackClient {
// hardcode — the fallback agrees with the mount instead of competing
// with it.
datasources: '/api/v1/datasources',
// [#6714] `email` became a declared `ApiRoutes` key (the base under
// which `POST {email}/send` is mounted), and this map is TOTAL over
// declared keys by design. `/api/v1/email` is not a guess: it is where
// `@objectstack/rest` mounts the surface on a default-base boot, so an
// unconnected client builds byte-identical URLs to the pre-#6714
// hardcode — the fallback agrees with the mount instead of competing
// with it.
email: '/api/v1/email',
};

return routeMap[type] || `/api/v1/${type}`;
}
}
Expand DownExpand Up@@ -4843,8 +4912,19 @@ export class ScopedProjectClient {
/** The environmentId this client is scoped to. */
getProjectId(): string { return this.environmentId; }

/** Prefix segment inserted between the baseUrl and the resource path. */
private scope(): string { return `/api/v1/environments/${encodeURIComponent(this.environmentId)}`; }
/**
* Prefix segment inserted between the baseUrl and the resource path.
*
* [#6714 face 3] The API base comes from the parent's discovery-derived
* `_apiBase()` rather than a hard-coded `/api/v1`: the server's scoped
* mount point is `getScopedBasePath(getApiBasePath())`, which follows
* `apiPath`, so a scoped client talking to an `apiPath` deployment built
* 404 URLs for every `meta` / `data` / `batch` / `packages` / `automation`
* call. An unconnected parent — or one whose advertised routes the base
* cannot be derived from — keeps building byte-identical
* `/api/v1/environments/...` URLs.
*/
private scope(): string { return `${this.parent._apiBase()}/environments/${encodeURIComponent(this.environmentId)}`; }

private url(suffix: string): string {
return `${this.parent._baseUrl()}${this.scope()}${suffix}`;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,20 @@ describe('[#4828] getDiscovery() conforms to DiscoverySchema', () => {
expect(Object.prototype.hasOwnProperty.call(discovery.routes, 'datasources')).toBe(false);
expect(declaredRouteKeys().has('datasources')).toBe(true);
});

it('[#6714] does NOT advertise `email` — this builder knows nothing about the email mount', async () => {
// Same disposition as `datasources` above: `registerEmailEndpoints`
// mounts `POST {base}/email/send` on the REST host (unconditionally,
// 501-degrading when no email service is configured), which this builder
// cannot see — and the runtime dispatcher serves no /email domain at
// all. Advertising here would be the advertise-the-unmounted half of
// ADR-0076 D12. The REST discovery endpoint advertises it from its
// recorded route registrations.
const discovery: any = await makeImpl().getDiscovery();

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

// ═════════════════════════════════════════════════════════════════════════
Expand Down
Loading
Loading