diff --git a/.changeset/meta-state-route-singular.md b/.changeset/meta-state-route-singular.md new file mode 100644 index 0000000000..24eaac429a --- /dev/null +++ b/.changeset/meta-state-route-singular.md @@ -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. diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index fefa6278f2..4ff7fd4711 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -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)" ], @@ -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" diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 06a8982739..cb07bae1c1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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); }, diff --git a/packages/client/src/meta-automation-descriptors.test.ts b/packages/client/src/meta-automation-descriptors.test.ts index d771abe491..de96679573 100644 --- a/packages/client/src/meta-automation-descriptors.test.ts +++ b/packages/client/src/meta-automation-descriptors.test.ts @@ -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', ); }); }); diff --git a/packages/qa/dogfood/test/meta-published-and-state-routes.dogfood.test.ts b/packages/qa/dogfood/test/meta-published-and-state-routes.dogfood.test.ts index 8192622e8f..4eeef032e2 100644 --- a/packages/qa/dogfood/test/meta-published-and-state-routes.dogfood.test.ts +++ b/packages/qa/dogfood/test/meta-published-and-state-routes.dogfood.test.ts @@ -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. @@ -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; @@ -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'); @@ -105,30 +105,47 @@ 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 () => { @@ -136,7 +153,7 @@ describe('dogfood: /meta/:type/:name/published and /meta/objects/:name/state/:fi // 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()); diff --git a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts index 0285544316..248c7aa378 100644 --- a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts @@ -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(); }); }); diff --git a/packages/rest/src/meta-route-registration-order.test.ts b/packages/rest/src/meta-route-registration-order.test.ts index 0b268bbf31..c07eef4aa2 100644 --- a/packages/rest/src/meta-route-registration-order.test.ts +++ b/packages/rest/src/meta-route-registration-order.test.ts @@ -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')); }); @@ -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'); + }); }); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 9d1ed25ee5..175a08b781 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -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', diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d46b17bf3d..aa604030bf 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -6013,7 +6013,7 @@ export class RestServer { }, }); - // GET /meta/objects/:name/state/:field?from=:state — ADR-0020 D3.3 + // GET /meta/object/:name/state/:field?from=:state — ADR-0020 D3.3 // legal-next-state introspection. [#7526] // // Ledgered since #3563 (`route-ledger.ts`, `meta.getLegalNextStates`) @@ -6022,85 +6022,91 @@ export class RestServer { // four, so no registration here could ever deliver it and it answered // Hono's `notFound`, byte-identical to an unmounted path. // + // SINGULAR ONLY, and the SDK calls this path (#9180 step 2): the + // `/meta` type segment is always singular, so the plural twin that + // used to be registered beside this one is retired here. + // + // What the retirement does NOT touch, because it is a different + // mechanism: the plural was a DECLARED registration, never a + // `META_URL_TO_SINGULAR` fold tolerance. This mount matches on a + // LITERAL segment, so no request for it ever reached the fold — the + // boundary's accept set for `/meta/:type/...` is unchanged, which is + // what the 2026-08-17 re-weigh (item 3) requires of this step. + // // Registered BEFORE the compound `/:type/:section/:name/published` - // twin below, which it collides with on exactly one shape: - // `/meta/objects/x/state/published`. Two literal segments (`objects`, + // twin below, which it still collides with on exactly one shape: + // `/meta/object/x/state/published`. Two literal segments (`object`, // `state`) beat one, so the FSM reading wins that path — a field // literally named `published` is the ambiguity, and answering it as - // "the published version of the compound name objects/x/state" would - // be the less likely of the two by a wide margin. - // - // `/object` as well as `/objects`: `metadata-protocol` folds the two - // spellings (#4432) and the dispatcher branch accepts both, so the - // REST mount that replaces it must not be pickier than what it - // replaces. - for (const objectsSegment of ['objects', 'object']) { - this.routeManager.register({ - method: 'GET', - path: `${metaPath}/${objectsSegment}/:name/state/:field`, - handler: async (req: any, res: any) => { + // "the published version of the compound name object/x/state" would + // be the less likely of the two by a wide margin. The collision + // OUTLIVES the plural, so the order pin keeps its singular arm; only + // the plural's own line in it goes away. + this.routeManager.register({ + method: 'GET', + path: `${metaPath}/object/:name/state/:field`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const name = String(req.params?.name ?? ''); + const field = String(req.params?.field ?? ''); + // [#6877 shape] `?from=` narrows to ONE current state; + // an array would reach `legalNextStates` as a + // stringified pair and match no transition key. + if (refuseRepeatedQueryParams(req, res, ['from'])) return; + const from = req.query?.from !== undefined ? String(req.query.from) : undefined; + const ql = this.objectQLProvider + ? await this.objectQLProvider(environmentId).catch(() => undefined) + : undefined; + const schema = (ql as any)?.registry?.getObject?.(name); + if (!schema) { + // `{ error: { code, message } }`, the envelope + // `BaseResponseSchema` declares — not the bare + // `{ error: 'string' }` the dispatcher branch this + // mirrors emits. `pnpm check:route-envelope` + // ratchets both non-conforming shapes DOWN only, so + // a new route arrives conforming or not at all. + res.status(404).json({ + error: { code: 'NOT_FOUND', message: 'Object not found' }, + }); + return; + } + // Dynamic import, matching the dispatcher branch this + // mirrors: `@objectstack/objectql` is a devDependency + // here, so a deployment serving REST without the data + // engine must degrade rather than fail to load. + let legalNextStates: + | ((s: { validations?: unknown[] } | null | undefined, f: string, c: string) => string[] | null) + | undefined; try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const name = String(req.params?.name ?? ''); - const field = String(req.params?.field ?? ''); - // [#6877 shape] `?from=` narrows to ONE current state; - // an array would reach `legalNextStates` as a - // stringified pair and match no transition key. - if (refuseRepeatedQueryParams(req, res, ['from'])) return; - const from = req.query?.from !== undefined ? String(req.query.from) : undefined; - const ql = this.objectQLProvider - ? await this.objectQLProvider(environmentId).catch(() => undefined) - : undefined; - const schema = (ql as any)?.registry?.getObject?.(name); - if (!schema) { - // `{ error: { code, message } }`, the envelope - // `BaseResponseSchema` declares — not the bare - // `{ error: 'string' }` the dispatcher branch this - // mirrors emits. `pnpm check:route-envelope` - // ratchets both non-conforming shapes DOWN only, so - // a new route arrives conforming or not at all. - res.status(404).json({ - error: { code: 'NOT_FOUND', message: 'Object not found' }, - }); - return; - } - // Dynamic import, matching the dispatcher branch this - // mirrors: `@objectstack/objectql` is a devDependency - // here, so a deployment serving REST without the data - // engine must degrade rather than fail to load. - let legalNextStates: - | ((s: { validations?: unknown[] } | null | undefined, f: string, c: string) => string[] | null) - | undefined; - try { - ({ legalNextStates } = await import('@objectstack/objectql')); - } catch { - legalNextStates = undefined; - } - if (typeof legalNextStates !== 'function') { - res.status(501).json({ - error: { - code: 'NOT_IMPLEMENTED', - message: 'State-machine introspection is not available in this runtime', - }, - }); - return; - } - // `next: null` = no FSM governs the field; `next: []` = - // a declared dead end. Same three-valued answer the - // dispatcher gives, because a UI asking "where can this - // record go" must be able to tell those apart. - const next = from === undefined ? null : legalNextStates(schema, field, from); - res.json({ object: name, field, from: from ?? null, next }); - } catch (error: any) { - handleRouteError(res, error); + ({ legalNextStates } = await import('@objectstack/objectql')); + } catch { + legalNextStates = undefined; } - }, - metadata: { - summary: 'List the legal next states declared by an object field\'s state machine', - tags: ['metadata'], - }, - }); - } + if (typeof legalNextStates !== 'function') { + res.status(501).json({ + error: { + code: 'NOT_IMPLEMENTED', + message: 'State-machine introspection is not available in this runtime', + }, + }); + return; + } + // `next: null` = no FSM governs the field; `next: []` = + // a declared dead end. Same three-valued answer the + // dispatcher gives, because a UI asking "where can this + // record go" must be able to tell those apart. + const next = from === undefined ? null : legalNextStates(schema, field, from); + res.json({ object: name, field, from: from ?? null, next }); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'List the legal next states declared by an object field\'s state machine', + tags: ['metadata'], + }, + }); // GET /meta/:type/:name/published — ADR-0033 published snapshot. [#7526] // diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 4426547d6f..5a4d13ce01 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -349,7 +349,8 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'GET /meta/_drafts', domain: '/meta', disposition: 'sdk', client: 'meta.listDrafts' }, { route: 'POST /meta/_migrate-stored', domain: '/meta', disposition: 'sdk', client: 'meta.migrateStored', note: 'ADR-0087 stored-row canonicalization (#4327); gated on `manage_metadata`, preview unless { apply: true }' }, - { route: 'GET /meta/objects/:name/state/:field', domain: '/meta', disposition: 'sdk', client: 'meta.getLegalNextStates' }, + { route: 'GET /meta/object/:name/state/:field', domain: '/meta', disposition: 'sdk', client: 'meta.getLegalNextStates', + note: '#9180 step 2 moved the SDK to the singular spelling and retired the plural REST registration; this row follows the client. The legacy if-chain branch in `domains/meta.ts` still matches BOTH literals (`objects` and `object`) — that tolerance is out of step 2 scope and is not narrowed here, so this row lists the canonical spelling of a branch that answers two' }, // ── data (legacy chain) ─────────────────────────────────────────────────── { route: 'POST /data/:object/query', domain: '/data', disposition: 'sdk', client: 'data.query' },