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
32 changes: 32 additions & 0 deletions .changeset/meta-state-route-singular.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/client": minor
"@objectstack/rest": minor
"@objectstack/runtime": minor
---

The `/meta` FSM state route is singular: `meta.getLegalNextStates` moves, the plural registration is retired (#10077)

Step 2 of the #9180 ruling — the `/meta` type segment is always singular, no
exception and no tolerated plural alias. Maintainer re-weigh, 2026-08-17,
verbatim: 「② 照原样做;只需要修正 objectstack objectui cloud 中错误的写法。」

- `client.meta.getLegalNextStates(object, field, from?)` now requests
`GET /api/v1/meta/object/:name/state/:field`. Same method, same arguments,
same response body — only the path segment changes.
- `GET /api/v1/meta/objects/:name/state/:field` is **no longer registered**.
The singular twin has been mounted alongside it since #7526, so the
migration for a hand-rolled HTTP caller is to drop the `s`. A request to the
retired spelling now gets the transport 404, which is the loud answer; the
one shape that changes hands rather than 404ing is a field literally named
`published`, which the compound `/:type/:section/:name/published` route
picks up.
- The two route ledgers follow what is mounted and what the SDK calls: the
plural row is deleted from `rest-route-ledger.ts` and the dispatcher ledger's
mirror row is respelled.

**What this does not change.** The boundary fold `META_URL_TO_SINGULAR` is
untouched, so no `/meta/:type/...` spelling that is accepted today becomes
refused: the retired route matched a **literal** path segment and never
consulted the fold. The 2026-08-17 re-weigh (item 3) defers that break with no
scheduled window. The legacy dispatcher branch in `runtime/src/domains/meta.ts`
also still matches both literals; narrowing it is not part of this step.
4 changes: 2 additions & 2 deletions docs/qa/platform-checklist/areas/api-backend.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -657,7 +657,7 @@
"capture status + code per route",
"read the OTHER ledgers and fire one representative route each: the dispatcher ledger (packages/runtime/src/route-ledger.ts) families share-links/keys/notifications/suggested-bindings/i18n/analytics (e.g. GET /api/v1/share-links, POST /api/v1/keys, GET /api/v1/notifications, GET /api/v1/security/suggested-bindings, GET /api/v1/i18n/locales, POST /api/v1/analytics/query), AUTH_ROUTE_LEDGER (GET /api/v1/auth/get-session), the storage + i18n service ledgers",
"fire the NON-LEDGERED mounts: GET /api/settings (note the /api/settings base — NOT /api/v1), GET /api/v1/datasources/drivers, GET /api/v1/datasources",
"fire the dispatcher meta state route: GET /api/v1/meta/objects/showcase_task/state/status?from=in_review, the same with ?from omitted, and GET /api/v1/meta/objects/not_a_real_object/state/status as the 404 control",
"fire the dispatcher meta state route: GET /api/v1/meta/object/showcase_task/state/status?from=in_review, the same with ?from omitted, and GET /api/v1/meta/object/not_a_real_object/state/status as the 404 control",
"fire one deliberately-unmounted path (GET /api/v1/definitely-not-a-route) as the 404 control",
"compare GET /api/v1/discovery capability bits against the families that answered (search/export/transactionalBatch at minimum)"
],
Expand DownExpand Up@@ -711,7 +711,7 @@
"evidence": "the two traces + the unledgered-mount finding"
},
{
"clause": "the dispatcher meta state route is live AND correct: GET /api/v1/meta/objects/showcase_task/state/status?from=in_review (dispatcher ledger meta.getLegalNextStates, ADR-0020 D3.3) answers non-404 and returns next == ['done','in_progress'] — exactly the declared task_status_flow transition set for that state; ?from omitted returns next:null (no from ⇒ no transition table), a field with no FSM returns next:null, and an unknown object → 404",
"clause": "the dispatcher meta state route is live AND correct: GET /api/v1/meta/object/showcase_task/state/status?from=in_review (dispatcher ledger meta.getLegalNextStates, ADR-0020 D3.3) answers non-404 and returns next == ['done','in_progress'] — exactly the declared task_status_flow transition set for that state; ?from omitted returns next:null (no from ⇒ no transition table), a field with no FSM returns next:null, and an unknown object → 404",
"oracle": "api",
"verify": "the state-route response's next[] equals the object's state_machine transitions for the from-state (examples/app-showcase/src/data/objects/task.object.ts task_status_flow: in_review → [done, in_progress]); the null/404 controls hold",
"evidence": "the state-route responses (from=in_review, from-omitted, unknown-object) vs the declared transitions"
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -874,7 +874,7 @@ export class ObjectStackClient {
const route = this.getRoute('metadata');
const qs = from !== undefined ? `?from=${encodeURIComponent(from)}` : '';
const res = await this.fetch(
`${this.baseUrl}${route}/objects/${encodeURIComponent(object)}/state/${encodeURIComponent(field)}${qs}`,
`${this.baseUrl}${route}/object/${encodeURIComponent(object)}/state/${encodeURIComponent(field)}${qs}`,
);
return this.unwrapResponse<{ object: string; field: string; from: string | null; next: string[] | null }>(res);
},
Expand Down
9 changes: 6 additions & 3 deletions packages/client/src/meta-automation-descriptors.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,21 +43,24 @@ describe('client.meta (#3563 PR-5)', () => {
expect(String(fetchMock.mock.calls[1][0])).toBe('http://localhost:3000/api/v1/meta/_drafts');
});

it('getLegalNextStates hits the FSM route and forwards from=', async () => {
// #9180 step 2: the segment is SINGULAR. This pin is the SDK half of that
// flip — the plural registration it used to call no longer exists on the
// REST surface, so a regression here is a 404 in the field, not a style slip.
it('getLegalNextStates hits the singular FSM route and forwards from=', async () => {
const { client, fetchMock } = createMockClient({
success: true,
data: { object: 'crm_lead', field: 'status', from: 'new', next: ['contacted'] },
});
const out = await client.meta.getLegalNextStates('crm_lead', 'status', 'new');
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/meta/objects/crm_lead/state/status?from=new',
'http://localhost:3000/api/v1/meta/object/crm_lead/state/status?from=new',
);
expect(out.next).toEqual(['contacted']);

// Omitted `from` → no query; the server answers next: null.
await client.meta.getLegalNextStates('crm_lead', 'status');
expect(String(fetchMock.mock.calls[1][0])).toBe(
'http://localhost:3000/api/v1/meta/objects/crm_lead/state/status',
'http://localhost:3000/api/v1/meta/object/crm_lead/state/status',
);
});
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,7 @@
// * GET /meta/:type/:name/published — ADR-0033 published snapshot.
// 404 for a name nothing declares. 200 with the current definition for one
// that exists but was never published (getPublished's documented fallback).
// * GET /meta/objects/:name/state/:field — ADR-0020 D3.3 legal next states.
// * GET /meta/object/:name/state/:field — ADR-0020 D3.3 legal next states.
// `next: null` when no state_machine governs the field or no `?from=` was
// given, `next: [...]` for a declared transition, `[]` for a dead end.

Expand All@@ -27,7 +27,7 @@ import { bootStack, type VerifyStack } from '@objectstack/verify';
import { MetadataPlugin } from '@objectstack/metadata';
import { writeBuildShapedArtifact } from './build-shaped-artifact.js';

describe('dogfood: /meta/:type/:name/published and /meta/objects/:name/state/:field (#7526)', () => {
describe('dogfood: /meta/:type/:name/published and /meta/object/:name/state/:field (#7526)', () => {
let stack: VerifyStack;
let token: string;
let tempDir: string;
Expand DownExpand Up@@ -91,10 +91,10 @@ describe('dogfood: /meta/:type/:name/published and /meta/objects/:name/state/:fi
});
});

describe('GET /meta/objects/:name/state/:field', () => {
describe('GET /meta/object/:name/state/:field', () => {
it('returns the legal next states declared by the field\'s state_machine', async () => {
// showcase_task declares `todo → [in_progress, backlog]`.
const res = await stack.apiAs(token, 'GET', '/meta/objects/showcase_task/state/status?from=todo');
const res = await stack.apiAs(token, 'GET', '/meta/object/showcase_task/state/status?from=todo');
expect(res.status).toBe(200);
const body = await res.json() as { object: string; field: string; from: string | null; next: string[] | null };
expect(body.object).toBe('showcase_task');
Expand All@@ -105,38 +105,55 @@ describe('dogfood: /meta/:type/:name/published and /meta/objects/:name/state/:fi

it('distinguishes "no FSM / no from" (null) from "a dead end" ([])', async () => {
// No `?from=` — the caller asked nothing answerable, so `next` is null.
const noFrom = await stack.apiAs(token, 'GET', '/meta/objects/showcase_task/state/status');
const noFrom = await stack.apiAs(token, 'GET', '/meta/object/showcase_task/state/status');
expect(((await noFrom.json()) as { next: unknown }).next).toBeNull();

// A field with no state_machine at all is also `null`, not `[]`: "nothing
// governs this" and "this state goes nowhere" are different facts and a
// UI has to be able to tell them apart (ADR-0020 D3.3).
const noRule = await stack.apiAs(token, 'GET', '/meta/objects/showcase_task/state/title?from=anything');
const noRule = await stack.apiAs(token, 'GET', '/meta/object/showcase_task/state/title?from=anything');
expect(noRule.status).toBe(200);
expect(((await noRule.json()) as { next: unknown }).next).toBeNull();

// An unknown state under a field that DOES have a machine is a dead end.
const deadEnd = await stack.apiAs(token, 'GET', '/meta/objects/showcase_task/state/status?from=not_a_state');
const deadEnd = await stack.apiAs(token, 'GET', '/meta/object/showcase_task/state/status?from=not_a_state');
expect(((await deadEnd.json()) as { next: unknown }).next).toEqual([]);
});

it('404s for an object the registry does not know', async () => {
const res = await stack.apiAs(token, 'GET', '/meta/objects/zzz_not_a_real_object/state/status?from=x');
const res = await stack.apiAs(token, 'GET', '/meta/object/zzz_not_a_real_object/state/status?from=x');
expect(res.status).toBe(404);
});

it('accepts the singular `/meta/object/...` spelling the dispatcher branch accepted', async () => {
const res = await stack.apiAs(token, 'GET', '/meta/object/showcase_task/state/status?from=todo');
expect(res.status).toBe(200);
expect(((await res.json()) as { next: string[] }).next.sort()).toEqual(['backlog', 'in_progress']);
it('no longer answers the retired plural `/meta/objects/...` spelling (#9180 step 2)', async () => {
// The ruling's substance over real HTTP: the `/meta` type segment is
// singular, so the plural registration is gone and the router has
// nothing to match. Measured, not assumed — the declarative-endpoint
// fallback seam declines every path outside `/apps/**`
// (`dispatcher-plugin.ts`, `isAppEndpointPath`), so no second surface
// picks this up and the transport's own 404 is the whole answer.
const retired = await stack.apiAs(token, 'GET', '/meta/objects/showcase_task/state/status?from=todo');
expect(retired.status).toBe(404);

// …and it is the TRANSPORT 404, byte-identical to a path nothing
// mounts — the shape #7526 measured for an unregistered route. A
// handler's 404 here would mean the plural is still being served
// somewhere.
const unmounted = await stack.apiAs(token, 'GET', '/meta/definitely/not/a/mounted/path');
expect(await retired.text()).toBe(await unmounted.text());

// The control that keeps this pin honest: the singular twin answers.
const singular = await stack.apiAs(token, 'GET', '/meta/object/showcase_task/state/status?from=todo');
expect(singular.status).toBe(200);
expect(((await singular.json()) as { next: string[] }).next.sort()).toEqual(['backlog', 'in_progress']);
});

it('is not the transport 404 — an unmounted control answers differently', async () => {
// The pre-fix state of this route: Hono's `notFound`, byte-identical to a
// path nothing mounts. Both 404s below are 404s; only one of them is a
// HANDLER's answer, and that difference is the whole point.
const control = await stack.apiAs(token, 'GET', '/meta/objects/showcase_task/state/status/definitely/not/mounted');
const handled = await stack.apiAs(token, 'GET', '/meta/objects/zzz_not_a_real_object/state/status?from=x');
const handled = await stack.apiAs(token, 'GET', '/meta/object/zzz_not_a_real_object/state/status?from=x');
expect(control.status).toBe(404);
expect(handled.status).toBe(404);
expect(await handled.text()).not.toBe(await control.text());
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -414,7 +414,11 @@ describe('route ledger ↔ live mount parity (#7526)', () => {
it('the three #7526 routes resolve to themselves and not to a catch-all sibling', () => {
expect(server.resolveMountedRoute!('GET', '/api/v1/meta/object/lead/published'))
.toEqual({ method: 'GET', pattern: '/api/v1/meta/:type/:name/published' });
expect(server.resolveMountedRoute!('GET', '/api/v1/meta/object/showcase_task/state/status'))
.toEqual({ method: 'GET', pattern: '/api/v1/meta/object/:name/state/:field' });
// #9180 step 2 retired the plural twin: the live router resolves it to
// NOTHING now, which is the fact the ledger's deleted row claims.
expect(server.resolveMountedRoute!('GET', '/api/v1/meta/objects/showcase_task/state/status'))
.toEqual({ method: 'GET', pattern: '/api/v1/meta/objects/:name/state/:field' });
.toBeUndefined();
});
});
26 changes: 22 additions & 4 deletions packages/rest/src/meta-route-registration-order.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,10 +113,14 @@ describe('/meta registration order', () => {

it('registers the FSM state read before the compound `/published` twin they collide on', () => {
const order = metaRoutesInOrder();
// The single colliding path is `/meta/objects/x/state/published`. Two
// The single colliding path is `/meta/object/x/state/published`. Two
// literal segments beat one, so the state-machine reading must win it.
expect(indexOf(order, 'GET /api/v1/meta/objects/:name/state/:field'))
.toBeLessThan(indexOf(order, 'GET /api/v1/meta/:type/:section/:name/published'));
//
// #9180 step 2 deleted the plural twin's arm of this pin along with the
// plural registration — but NOT the pin: the collision is between the FSM
// read and the compound `/published` route, and it outlives the spelling
// that was retired. Deleting the whole pin would have un-guarded the
// surviving route against exactly the #7526 defect it exists to catch.
expect(indexOf(order, 'GET /api/v1/meta/object/:name/state/:field'))
.toBeLessThan(indexOf(order, 'GET /api/v1/meta/:type/:section/:name/published'));
});
Expand All@@ -126,9 +130,23 @@ describe('/meta registration order', () => {
for (const key of [
'GET /api/v1/meta/types',
'GET /api/v1/meta/:type/:name/published',
'GET /api/v1/meta/objects/:name/state/:field',
'GET /api/v1/meta/object/:name/state/:field',
]) {
expect(order, `${key} is not registered — this is the #7526 defect returning`).toContain(key);
}
});

it('no longer registers the plural FSM state read (#9180 step 2)', () => {
// The retirement is the ruling's substance, so it is pinned as a fact
// about the mount table rather than left to the ledger's prose: the
// `/meta` type segment is singular, always, and re-adding the plural
// registration would restore the two-dialect surface the ruling retired.
//
// This asserts the withdrawal of a DECLARED route only. It says nothing
// about `META_URL_TO_SINGULAR`, which this route never consulted (it
// matches a literal segment, not a `:type` param) and which step 2 leaves
// exactly as it found it.
expect(metaRoutesInOrder())
.not.toContain('GET /api/v1/meta/objects/:name/state/:field');
});
});
14 changes: 6 additions & 8 deletions packages/rest/src/rest-route-ledger.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -188,14 +188,12 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
// at runtime. Both are `route-manager` mounts here now.
//
// Order is load-bearing and pinned by `meta-route-registration-order.test.ts`:
// the `/state/:field` pair precedes the compound `/published` twin (they
// collide only on a field literally named `published`), and BOTH `/published`
// rows precede `GET /api/v1/meta/:type/:section/:name` — a three-segment
// literal registered after that catch-all is mounted and unreachable.
{ route: 'GET /api/v1/meta/objects/:name/state/:field', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getLegalNextStates',
note: 'ADR-0020 D3.3 legal-next-state introspection. `next: null` = no state_machine governs the field, `next: []` = a declared dead end; the SDK spells the segment `objects`' },
{ route: 'GET /api/v1/meta/object/:name/state/:field', family: 'metadata', source: 'route-manager', disposition: 'server-only',
note: 'singular-spelling alias of the row above — metadata-protocol folds object/objects (#4432) and the dispatcher branch this mount replaces accepted both, so the replacement is not pickier than what it replaced. The SDK calls the plural only' },
// `/state/:field` precedes the compound `/published` twin (they collide only
// on a field literally named `published`), and BOTH `/published` rows precede
// `GET /api/v1/meta/:type/:section/:name` — a three-segment literal
// registered after that catch-all is mounted and unreachable.
{ route: 'GET /api/v1/meta/object/:name/state/:field', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getLegalNextStates',
note: 'ADR-0020 D3.3 legal-next-state introspection. `next: null` = no state_machine governs the field, `next: []` = a declared dead end. #9180 step 2 retired the plural `/api/v1/meta/objects/:name/state/:field` twin that used to carry this `sdk` disposition, and the SDK now spells the segment `object` — the `/meta` type segment is singular, always. The retired twin was a DECLARED registration, not a `META_URL_TO_SINGULAR` fold tolerance (this route matches a literal segment and never consulted the fold), so the boundary accept set is unchanged' },
{ route: 'GET /api/v1/meta/:type/:name/published', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getPublished',
note: 'ADR-0033 published snapshot; 404s for a name that does not exist, which the pre-#7526 fall-through into the compound-name route structurally could not do (it answered a protection-envelope stub identical before publish and for a bogus name)' },
{ route: 'GET /api/v1/meta/:type/:section/:name/published', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getPublished',
Expand Down
Loading
Loading