diff --git a/.changeset/23050-s13e-meta-object-state-published-move.md b/.changeset/23050-s13e-meta-object-state-published-move.md new file mode 100644 index 00000000000..0e6a45a1efa --- /dev/null +++ b/.changeset/23050-s13e-meta-object-state-published-move.md @@ -0,0 +1,10 @@ +--- +'@objectstack/rest': patch +--- + +The `/meta` object state-machine read and the published read live in their own modules, with no route, handler or answer changed + +Clause-②: no (a move: the two routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical) + +- `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head, its per-item registrar included. The statement that registers `GET {metaPath}/object/:name/state/:field` (the legal next states of an object field's state machine) moved, re-indented, to `packages/rest/src/meta-object-state-routes.ts`, and the block that registers `GET {metaPath}/:type/:name/published` (an item's published version) moved to `packages/rest/src/meta-item-history-routes.ts`, beside the history family. The method calls each at its old position, inside the umbrella's guarded registration. The state read still answers to `api.enableMetadata` alone, and the published read still registers through the per-item registrar, so the `metadata.endpoints.item` switch gates the same mounts. +- Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index e952963b09b..4e1dd5090f1 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1638,7 +1638,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', ( // [#23050 slice 13b] Length 14 → 15 (`meta-item-read-routes.ts`), still 0 over it. // [#23050 slice 13c] Length 15 → 16 (`meta-item-write-routes.ts`), still 0 over it. // [#23050 slice 13d] Length 16 → 17 (`meta-item-history-routes.ts`), still 0 over it. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); + // [#23050 slice 13e] Length 17 → 18 (`meta-object-state-routes.ts`), still 0 over it. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(18); expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER); const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8')); for (const probe of [MECHANICS, ROUTES]) { diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index 104a7117f10..9cd3717f60a 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -541,17 +541,17 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ '`data-action-routes.ts`, `batch-routes.ts`, `approvals-routes.ts`, `sharing-routes.ts`, ' + '`analytics-routes.ts`, `security-routes.ts`, `discovery-routes.ts`, `email-routes.ts`, ' + '`search-routes.ts`, `ui-routes.ts`, `form-routes.ts`, `crud-routes.ts`, `meta-collection-routes.ts`, ' + - '`meta-item-read-routes.ts`, `meta-item-write-routes.ts` and `meta-item-history-routes.ts`, ' + - 'the modules the bodies of ' + + '`meta-item-read-routes.ts`, `meta-item-write-routes.ts`, `meta-item-history-routes.ts` and ' + + '`meta-object-state-routes.ts`, the modules the bodies of ' + '`registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' + '`registerSharingEndpoints`, `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, ' + '`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints`, ' + '`registerOpenApiEndpoints`, `registerEmailEndpoints`, `registerSearchEndpoints`, ' + - '`registerUiEndpoints`, `registerFormEndpoints` and `registerCrudEndpoints`, and the first six `/meta` ' + + '`registerUiEndpoints`, `registerFormEndpoints` and `registerCrudEndpoints`, and all eight `/meta` ' + 'groups of `registerMetadataEndpointsInner`, moved to; ' + 'reachable = those inside registerMetadataEndpoints, PLUS every site in the registrar list\'s named `/meta` ' + 'subset (`REST_SERVER_META_ROUTE_FILES`: `meta-collection-routes.ts`, `meta-item-read-routes.ts`, ' + - '`meta-item-write-routes.ts` and `meta-item-history-routes.ts`), whose ' + + '`meta-item-write-routes.ts`, `meta-item-history-routes.ts` and `meta-object-state-routes.ts`), whose ' + 'registrations the inner method ' + 'runs inside the umbrella\'s swap', // [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and @@ -910,10 +910,31 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // `const registerPerItemRoute =` stays 1, `forwarder slice:` stays 1 and // `enforceAuth` stays 112: the moved group spells none of them, and neither // does the seam. + // [#23050 slice 13e] 70 / 19 / 51 UNCHANGED, `reachable` held by 13a's rule + // with no rule change, and ONE control re-keyed. The seventh `/meta` group, + // the object state-machine read (one DIRECT registration site, in no + // per-family switch), moved to `meta-object-state-routes.ts`, which joins the + // list (length 17 -> 18) and the named `/meta` subset (4 -> 5) with its own + // control on `this.routeManager.register(` (1). The eighth and last, the + // published read (one helper-routed site), joined `meta-item-history-routes.ts`, + // whose own control goes 5 -> 6, and the whole-population + // `registerPerItemRoute(` control stays 8 over this file plus every module on + // the list (0 here + 2 + 6). Measured at the move commit, before the list + // learned the new module: population 69, the state read's site out of reach. + // RE-KEYED, not relaxed: the inner method now calls the history module TWICE + // (the history family, and the published read at its own position after the + // state read), so `.call(` inside the inner method reads 6 against a subset of + // 5 files, and is recorded as the subset's length PLUS that one second call; + // a `/meta` module called without joining the subset, or a subset entry no + // longer called, still moves it. `this.routeManager.register(` stays 63 over + // the eighteen files (one site moved from here to the new module), + // `private register*Endpoints(` stays 16, `const registerPerItemRoute =` + // stays 1, `forwarder slice:` stays 1 and `enforceAuth` stays 112: the moved + // groups spell none of the last four, and neither does the seam. controls: { 'private register*Endpoints(': 16, 'this.routeManager.register(': 63, - 'registrar list length': 17, + 'registrar list length': 18, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, 'approvals-routes.ts: this.routeManager.register(': 7, @@ -932,11 +953,16 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // has no direct site (see above). 'meta-item-write-routes.ts: registerPerItemRoute(': 2, // [#23050 slice 13d] The same: the history family's module has no direct site. - 'meta-item-history-routes.ts: registerPerItemRoute(': 5, + // [#23050 slice 13e] 5 -> 6: the published read joined it, helper-routed too. + 'meta-item-history-routes.ts: registerPerItemRoute(': 6, + // [#23050 slice 13e] The object state-machine read registers directly. + 'meta-object-state-routes.ts: this.routeManager.register(': 1, // [#23050 slice 13a] Recorded AS the subset's length, not as a literal: the // measured side is the module calls inside the inner method, so the two // can only move together. - '/meta subset ↔ .call( inside registerMetadataEndpointsInner': REST_SERVER_META_ROUTE_FILES.length, + // [#23050 slice 13e] …PLUS ONE: `meta-item-history-routes.ts` is the one + // module the inner method calls at two positions (see above). + '/meta subset ↔ .call( inside registerMetadataEndpointsInner': REST_SERVER_META_ROUTE_FILES.length + 1, // Both halves of the new rule carry their own control, so neither can go // silently to zero: a helper deleted and its routes inlined back would // still read population 80, and only these two controls would notice the @@ -1478,6 +1504,7 @@ export function deriveProbeFileCensus(): { 'forwarder slice: this.routeManager.register(': occurrences(forwarderSlice(src), mountRe), // [#23050 slice 13a] The module calls inside the inner method — one per // moved `/meta` group's module — recorded against the subset's length. + // [#23050 slice 13e] Two for `meta-item-history-routes.ts`, recorded as such. '/meta subset ↔ .call( inside registerMetadataEndpointsInner': occurrences(inner, /\.call\(/g), enforceAuth: occurrences(all, /enforceAuth/g), }, diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 1af55c26c9c..d139fbbf642 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -140,6 +140,11 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); * lines, the change log's and the comparison's on their continuation lines — * two of §7's and §8's four continuation sites, which those sections find there * because they read every file here. + * + * [#23050 slice 13e] …and the seventh and eighth, the object state-machine read + * to `meta-object-state-routes.ts` and the published read to + * `meta-item-history-routes.ts`, with none: neither door resolves a caller + * itself (the umbrella's wrapper does, before either handler runs). */ const SITE_FILES = REST_SERVER_REGISTRAR_FILES; const SOURCES = new Map(SITE_FILES.map((file) => [ @@ -157,7 +162,7 @@ const siteKey = (file: string, line: number) => `${file}:${line}`; * `registerSearchEndpoints`, `registerUiEndpoints`, * `registerCrudEndpoints`) hands its moved body — and each one * `registerMetadataEndpointsInner` builds for a moved `/meta` group's module - * (#23050 slices 13a, 13b, 13c and 13d), counted as that module's registrar — + * (#23050 slices 13a to 13e), counted as that module's registrar — * forwards each member to the server with `(...a) => this.(...a)`, so * a moved site's stack shows * that forwarder above the site itself. The site is the first censused frame @@ -182,11 +187,17 @@ const FORWARDERS = new Set(FORWARDER_LINES.map((line) => siteKey('rest-server.ts * bodies), each handed over by its own host literal — but see * {@link MOVED_REGISTRARS_READING_NO_EXECCTX}: only one of its two literals * carries the forwarder. + * + * [#23050 slice 13e] So does `meta-item-history-routes.ts` (the history family + * and the published read), each handed over by its own host literal the inner + * method builds at its own position — and, as for the discovery module, only + * the first of the two carries the forwarder. */ const EXTRA_MOVED_REGISTRARS: Readonly>> = { 'sharing-routes.ts': 1, 'security-routes.ts': 1, 'discovery-routes.ts': 1, + 'meta-item-history-routes.ts': 1, }; /** * [#23050 slice 7] The moved registrars whose bodies read no `resolveExecCtx`, @@ -201,10 +212,18 @@ const EXTRA_MOVED_REGISTRARS: Readonly>> = { 'discovery-routes.ts': 1, 'form-routes.ts': 1, + 'meta-item-history-routes.ts': 1, + 'meta-object-state-routes.ts': 1, }; /** A stack frame in any censused file, as `::`. */ @@ -802,13 +821,24 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // out, one forwarder's two mentions in). Seventeen forwarders now: // (17 - 1) + 3 - 2, the module carrying one host literal that reads the seam, // so neither map gains an entry. - expect(SITE_FILES).toHaveLength(17); + // + // [#23050 slice 13e] 62 sites / 145 mentions UNCHANGED — the same MOVE. The + // seventh and eighth `/meta` groups, the object state-machine read and the + // published read, left for `meta-object-state-routes.ts` (a new module on the + // list) and `meta-item-history-routes.ts` (already on it) with NO site, and + // neither host literal the inner method builds for them names a + // `resolveExecCtx` member, so no mention moved either: + // 5 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 1 + 1 + 1 + 0 + 6 + 3 + 1 + 2 + 5 + 0 = 62 + // sites. Still seventeen forwarders: (18 - 1) + 4 - 4, the published read + // counted as the history module's second registrar in BOTH maps and the new + // module in `MOVED_REGISTRARS_READING_NO_EXECCTX`. + expect(SITE_FILES).toHaveLength(18); expect(SITES.length).toBe(62); expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(145); const extraMovedRegistrars = Object.values(EXTRA_MOVED_REGISTRARS).reduce((n, k) => n + (k ?? 0), 0); - expect(extraMovedRegistrars).toBe(3); + expect(extraMovedRegistrars).toBe(4); const registrarsReadingNoExecCtx = Object.values(MOVED_REGISTRARS_READING_NO_EXECCTX).reduce((n, k) => n + (k ?? 0), 0); - expect(registrarsReadingNoExecCtx).toBe(2); + expect(registrarsReadingNoExecCtx).toBe(4); expect(FORWARDER_LINES, 'one host-literal resolveExecCtx forwarder per moved registrar whose body reads it: one was respelt or removed, or a module moved without joining SITE_FILES') .toHaveLength(SITE_FILES.length - 1 + extraMovedRegistrars - registrarsReadingNoExecCtx); }); diff --git a/packages/rest/src/meta-draft-read-door-census.test.ts b/packages/rest/src/meta-draft-read-door-census.test.ts index 063a7cc937a..a4ecbf1a23d 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -280,7 +280,12 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', () // carrying no draft switch: none of its five doors reads one, so no ledger row // changed: // 8 = 0 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 3 + 2 + 1 + 0, length 16 → 17. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); + // [#23050 slice 13e] The object state-machine read moved to + // `meta-object-state-routes.ts`, and the published read to + // `meta-item-history-routes.ts`, carrying no draft switch: neither door reads + // one, so no ledger row changed: + // 8 = 0 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 3 + 2 + 1 + 0 + 0, length 17 → 18. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(18); expect(SOURCE_FILES.map((file) => file.fileName)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(SITES).toHaveLength(8); }); diff --git a/packages/rest/src/meta-item-history-routes.ts b/packages/rest/src/meta-item-history-routes.ts index 46d6413e328..5e850bcc6a6 100644 --- a/packages/rest/src/meta-item-history-routes.ts +++ b/packages/rest/src/meta-item-history-routes.ts @@ -8,12 +8,22 @@ * (`POST {metaPath}/:type/:name/rollback`) and the comparison of two stored * versions (`GET {metaPath}/:type/:name/diff`) — moved out of `rest-server.ts`. * They were the sixth group of `RestServer#registerMetadataEndpointsInner`'s - * body, five statements, and that method now calls the function below at their - * old position, so the `/meta` routes still register in one order: after the - * per-item writes, and above the object FSM read. + * body, five statements, and that method now calls the first function below at + * their old position, so the `/meta` routes still register in one order: after + * the per-item writes, and above the object FSM read. * - * A MOVE, not a redesign. The function below is those five statements as they - * stood, re-indented one level: the same five routes per base, by the same + * [#23050 slice 13e] The second function below, + * {@link registerMetaItemPublishedRoute}, is the eighth and last group of that + * body: the published read (`GET {metaPath}/:type/:name/published`), one bare + * block holding one route. It is a member of the same per-item face and goes + * through the same per-item registrar, so it lives beside the family. It is a + * function of its own because the object FSM read (now + * `meta-object-state-routes.ts`), which is no member of that face, registers + * between the two, and the inner method calls each at its own position. Its + * section is the last one in this header. + * + * A MOVE, not a redesign. The first function below is those five statements as + * they stood, re-indented one level: the same five routes per base, by the same * handlers, answering the same bodies, each still registered through the * per-item switch's registrar. The moved comments are moved text too: where one * says "the reset door above", "a few hundred lines up" or "this file", it @@ -85,29 +95,71 @@ * * ## Module-level names * - * Ten names declared in `rest-server.ts` stay there and are imported from it: - * the five doors' closed query sets (their tests import them from there), the - * declared numeric query read and its whole-number schema (the approvals, - * data-action and search modules import both from there too), the package - * binding, the absence answer (the class's event-door refusal sends it too), - * and the authoring-door refusal, which stays beside the predicate it asks - * (that predicate is a docs anchor) and gained `export` there rather than a - * second home. Every one of them is read only at request time, so the two - * modules importing each other cannot see one uninitialised. The imports only - * this body read left `rest-server.ts` with it: the two declared request - * schemas the change log and the audit trail parse their numbers through, the - * three declared request types and the member selector. Every other name is - * imported from the module `rest-server.ts` imports it from. The package entry - * re-exports nothing from here, and the published surface does not move. + * The history family reads ten names declared in `rest-server.ts`, which stay + * there and are imported from it: the five doors' closed query sets (their + * tests import them from there), the declared numeric query read and its + * whole-number schema (the approvals, data-action and search modules import + * both from there too), the package binding, the absence answer (the class's + * event-door refusal sends it too), and the authoring-door refusal, which stays + * beside the predicate it asks (that predicate is a docs anchor) and gained + * `export` there rather than a second home. Every one of them is read only at + * request time, so the two modules importing each other cannot see one + * uninitialised. The imports only this body read left `rest-server.ts` with + * it: the two declared request schemas the change log and the audit trail + * parse their numbers through, the three declared request types and the member + * selector. Every other name is imported from the module `rest-server.ts` + * imports it from. The package entry re-exports nothing from here, and the + * published surface does not move. + * + * ## The published read + * + * [#23050 slice 13e] {@link registerMetaItemPublishedRoute} is that bare block + * as it stood, re-indented one level, braces included: the same route per + * base, by the same handler, answering the same bodies, still registered + * through the per-item switch's registrar, which reaches it in the same + * {@link MetaRouteScope} as the ONE closure the inner head declares. So, as for + * the history family, there is no `routeManager` member, and ⛔ never a + * module-local registrar. Its host, {@link MetaItemPublishedRouteHost}, was + * walked on its own, not inherited from the history family's: + * + * - `objectQLProvider` goes as a GETTER, the one member in this module that + * does. The disabled-owner check reads that provider FIELD at REQUEST time, + * twice (its presence, then the call, both through `wiredEngineOrLoud`), and + * the field is not `readonly`, so a value copied at registration could part + * from it. The getter reads the field the handler read, when it read it: + * slice 2's batch shape, unbound, per the seat's provider ruling (no shipped + * provider reads `this`). + * - The masker, the protocol, the read gate, the schema mask and the metadata + * service are each resolved per request by a METHOD of the class, and every + * method goes as a closure. + * - The door names the requested type through the private static + * `metaTypeSingular`, which it used to call as + * `RestServer.metaTypeSingular(…)`; for the reason the section above gives, + * it now reaches it through the host, as `this.metaTypeSingular(…)`, a + * closure that calls the static with the same argument. That receiver is the + * only token of the body that changed. + * + * It destructures the same three scope bindings. Two more names declared in + * `rest-server.ts` are imported from it: `wiredEngineOrLoud` (the class head, + * the batch door and the object FSM read reach the engine through it too) and + * the `RestProtocol` type, the read gate's protocol annotation; both are read + * only at request time. The imports only this body read left `rest-server.ts` + * with it: the door's closed query set (its re-export there stays), the + * URL-type canonicaliser, the mask's posture relation and the unknown-parameter + * refusal; so did the route-error answer, whose one other reader there, the + * object FSM read, left in the same slice. Every other name is imported from + * the module `rest-server.ts` imported it from. */ import { rethrowAuthzStoreUnavailable } from '@objectstack/core'; // [#12702 · #20156] The `/meta` item write doors' capability predicate — see // `rest-server.ts`' import of the same name for why it is shared — and the -// metadata-plane FLS names the comparison door's mask reads. +// metadata-plane FLS names the comparison door's and the published read's mask +// reads. import { ObjectSchemaMaskEvaluationError, metaWriteCapabilityVerdict, + relateObjectSchemaMaskPosture, type ObjectSchemaMaskPosture, } from '@objectstack/metadata-core'; // [#20061 / #20062 / #20139] The DECLARED request schemas the change log and the @@ -115,9 +167,10 @@ import { // `readDeclaredQueryNumber` in `rest-server.ts`. import { AuditMetaItemRequestSchema, HistoryMetaItemRequestSchema } from '@objectstack/spec/api'; import type { AuditMetaItemRequest, HistoryMetaItemRequest, PublishMetaItemRequest } from '@objectstack/spec/api'; +import { canonicalMetaUrlType } from '@objectstack/spec/shared'; import { handleRouteError, sendFieldVisibilityFault, thrownAnswerIsBareNotFound } from './error-response.js'; import type { MetaRouteScope, TransportScopedMetaRequest } from './meta-collection-routes.js'; -import { META_ITEM_MEMBER_PARAM, metaMemberSelector } from './meta-item-read-gate.js'; +import { META_ITEM_MEMBER_PARAM, META_ITEM_PUBLISHED_PARAMS, metaMemberSelector } from './meta-item-read-gate.js'; import { refuseUnknownQueryParams } from './query-allowlist.js'; import { refuseRepeatedQueryParams } from './query-multiplicity.js'; import { @@ -131,6 +184,8 @@ import { readDeclaredQueryNumber, refuseNonAuthoringCaller, sendMetaItemAbsent, + wiredEngineOrLoud, + type RestProtocol, type RestServer, } from './rest-server.js'; @@ -995,3 +1050,319 @@ export function registerMetaItemHistoryRoutes(this: MetaItemHistoryRouteHost, sc }, }); } + +/** + * The `RestServer` members {@link registerMetaItemPublishedRoute} reads — every + * one of them, and no other, in the order the body first reads them. Each is + * typed AS the class member (an indexed access on the class), so a change to a + * member's signature on the class is a change here too, and naming a member the + * class does not have is a compile error. `metaTypeSingular` is the class's + * private STATIC, typed as the static it is. The module header's last section + * says why there is no `routeManager` member, why the provider field is a + * getter and why the body reaches the static through the host. + */ +export interface MetaItemPublishedRouteHost { + metaTypeSingular: (typeof RestServer)['metaTypeSingular']; + resolveObjectMasker: RestServer['resolveObjectMasker']; + resolveProtocol: RestServer['resolveProtocol']; + metaItemReadGate: RestServer['metaItemReadGate']; + maskObjectDocument: RestServer['maskObjectDocument']; + readonly objectQLProvider: RestServer['objectQLProvider']; + resolveMetadataService: RestServer['resolveMetadataService']; +} + +/** + * Register the `/meta` published read — the eighth and last group of + * `RestServer#registerMetadataEndpointsInner`' body, called by that method at + * its position in its registration order, after the object state-machine read, + * with the server's members as `this` and the method's head bindings as + * `scope`. The method's own docblock, which the published declaration carries, + * names the families, their switches and the ordering facts the route below + * keeps. + */ +export function registerMetaItemPublishedRoute(this: MetaItemPublishedRouteHost, scope: MetaRouteScope): void { + const { metaPath, isScoped, registerPerItemRoute } = scope; + + // GET /meta/:type/:name/published — ADR-0033 published snapshot. [#7526] + // + // Ledgered since #3563 (`meta.getPublished`) and implemented in the + // dispatcher, but never mounted here — so the request fell into the + // compound-name route below with `section=:name, name='published'`, + // which answered a protection-envelope stub. Identical before and + // after publish, identical for a name that does not exist: a route + // that structurally could not 404. + // + // ONE arity since commit 7986d973f (stage 3 of the maintainer-ruled + // retirement of compound-name addressing, 2026-08-25). This route used + // to be mounted twice — the second registration was + // `/:type/:section/:name/published`, folding `section` and `name` back + // into one slash-bearing key so the SDK's + // `getPublished('lead', 'views/all_leads')` could reach it. + // + // Stage 1 (commit 311433f6b) declared the item-name grammar and refuses every + // slash-bearing name at the publish door, so no name reachable ONLY + // through that arity can exist any more. What remains addressable is a + // pre-grammar residue row, and it is reachable HERE: a percent-encoded + // `%2F` matches this single-segment pattern and Hono decodes the + // parameter back to `views/all_leads` (measured, not assumed), which is + // the spelling the SDK now sends for every name. So the compound arity + // was removed WITHOUT removing the capability — D1's "any stored junk + // name remains listable and clearable" still holds through this door. + { + registerPerItemRoute({ + method: 'GET', + path: `${metaPath}/:type/:name/published`, + handler: async (req: any, res: any) => { + try { + // [#23072] Recognition first, over the measured closed + // set — empty: this door reads no query parameter. + if (refuseUnknownQueryParams(req, res, META_ITEM_PUBLISHED_PARAMS)) return; + const environmentId = isScoped ? req.params?.environmentId : undefined; + const type = String(req.params?.type ?? ''); + // [commit 7986d973f] No `section` fold: this route has one arity. + // A percent-encoded slash arrives already decoded here, + // so a residue name reads exactly as it is stored. + const name = String(req.params?.name ?? ''); + // [#8278] The AUTHORITATIVE published store is consulted + // first: the `state:'active'` `sys_metadata` overlay row. + // Mirrors the dispatcher fix (#8031 / PR #8254, + // `packages/runtime/src/domains/meta.ts`) onto the + // transport that actually serves the cloud runtime — the + // two doors answered the same question from two stores, + // and only one of them had been corrected. + // + // Two publish lifecycles write to two different places: + // + // - `MetadataManager.publishPackage` snapshots a body + // into the row-local `publishedDefinition` key of its + // own in-memory registry — the ADR-0016-era package + // publish, which is what `getPublished` below reads. + // - `publishPackageDrafts` / `promoteDraft` flips the + // artifact's `sys_metadata` row `state:'draft' → + // 'active'`. ADR-0027 (E)(5) defines sealing a publish + // as exactly that flip, `SysMetadataRepository` names + // `'active'` "the published, live overlay", and + // ADR-0033 §2 routes EVERY runtime authoring write + // into that same ADR-0027 draft — so promoting it is + // what "published" means for anything authored at + // runtime. Those items were absent from the registry + // `getPublished` consults, so this route answered 404 + // about an item that IS published. + // + // `getMetaItemLayered` is the narrow primitive on purpose. + // Its overlay layer is a strict `state:'active'` lookup + // that never reads a draft, and it reports that layer + // SEPARATELY from the code layer — so a null overlay is + // positively "no runtime-published row" and falls through + // to the untouched `getPublished` path below, which keeps a + // code-published item resolving to byte-identical bytes. + // The broader `getMetaItem` would not do: it folds the code + // layer into its own answer, so this route could no longer + // tell the two stores apart. + // + // [ADR-0131 D6] NO `organizationId`: the per-organization + // overlay axis is retired, so every publish this + // transport produces lands environment-wide, and this + // door resolves exactly those. A legacy + // organization-scoped row is not served; its promotion is + // ADR-0131 C7's. + // + // Environment scoping still holds: it comes from WHICH + // protocol `resolveProtocol` hands back, not from the + // request payload (`getMetaItemLayered` declares no + // `environmentId` member). + // [#20156] This door serves ONE document, the same + // representation the plain read serves — so it answers + // exactly what the plain read answers this caller: every + // gate arm (`all`), a partly-withheld app PRUNED, and the + // ADR-0106 object mask, its posture resolved BEFORE the + // fetch (D2/D3's `fetch → mask → send`). It ran none of + // them, so a member the plain read refuses + // `crm_admin_runbook` read its body here, and an object's + // unreadable fields were served whole. Both exits below + // send through `servePublished`. + const publishedMetaType = this.metaTypeSingular(type); + let publishedMaskPosture: ObjectSchemaMaskPosture; + try { + publishedMaskPosture = await (await this.resolveObjectMasker(environmentId, req, publishedMetaType))(name); + } catch (maskError: any) { + if (maskError instanceof ObjectSchemaMaskEvaluationError) { + sendFieldVisibilityFault(res, name); + return; + } + throw maskError; + } + let publishedProtocol: any; + try { + publishedProtocol = await this.resolveProtocol(environmentId, req); + } catch { /* fall through to the code/package snapshot below */ } + const servePublished = async (document: any): Promise => { + // The gate reads the protocol for its inputs (books, + // the doc corpus, the object registry). The swallowed + // resolution above is a fall-through for the SNAPSHOT; + // a gate input is never read as absent, so a type the + // gate judges resolves it again and lets it throw. + const gateProtocol: RestProtocol = publishedProtocol + ?? await this.resolveProtocol(environmentId, req); + const verdict = await this.metaItemReadGate( + environmentId, req, gateProtocol, publishedMetaType, name, [document], + { arms: 'all', app: 'gate' }, + )(document); + if (verdict.kind === 'refuse') { + verdict.send(res); + return; + } + let served = verdict.document; + if (publishedMaskPosture.kind === 'project') { + const related = await relateObjectSchemaMaskPosture(publishedMaskPosture, served); // [#21884] + const masked = this.maskObjectDocument(res, related, name, served); + if (!masked) return; + served = masked.document; + } else if (publishedMaskPosture.kind === 'undetermined') { + res.header('Cache-Control', 'private, no-store'); + } + res.json(served); + }; + // [#20156] The overlay found is SERVED outside the + // `try` below: that `catch` classifies a failed overlay + // READ, and a gate fault raised while serving must reach + // `handleRouteError` as itself — never fall through to + // the snapshot as though no overlay existed. + // [#7557, #24210] A disabled package's item is answered + // as a name with nothing behind it, as the plain read + // and the list answer it. Neither store below knows a + // disable (the layered read keeps serving its resolution + // reader, and the snapshot carries no owner), so the + // owner is the one the plain read's kept protection + // envelope names, `packageId`, asked of the registry's + // `isPackageDisabled`: the predicate the protocol asks of + // that same field. An object is the layered read's own + // refusal below; `package` is never hidden. + let withheld = false; + if (publishedMetaType !== 'object' && publishedMetaType !== 'package' + && typeof publishedProtocol?.getMetaItem === 'function') { + const ql = await wiredEngineOrLoud( + Boolean(this.objectQLProvider), + () => this.objectQLProvider!(environmentId), + ); + const registry = (ql as any)?.registry; + if (typeof registry?.isPackageDisabled === 'function') { + const envelope = await publishedProtocol.getMetaItem({ type, name }); + withheld = registry.isPackageDisabled(envelope?.packageId) === true; + } + } + let publishedOverlay: unknown; + if (!withheld && typeof publishedProtocol?.getMetaItemLayered === 'function') { + try { + const layered = await publishedProtocol.getMetaItemLayered({ + type, + name, + }); + if (layered?.overlay !== undefined && layered?.overlay !== null) { + // [#21002, #21986, ADR-0126 §2, ADR-0062 D4] + // When the layered read put a code layer + // over this stored row, this door serves + // that effective layer, not the row. The + // protocol decides it with one predicate, + // `declinesStoredRow`, for both name + // classes: a shipped flow name (the + // loader's body; `flow` is Regime C, + // "never an overlay read path") and a + // code-defined datasource name (the code + // definition; "code wins on collision"). + // The predicate is ASKED of its owner with + // the answer's own `type` / `name`, never + // re-derived here, so this door, the + // by-name read, the list and + // `getMetaItemLayered` read one rule. Every + // other stored row is served exactly as + // before — an `object` too, whose effective + // layer differs from its row by folding, not + // by this decision — and so is every row of a + // protocol that brings no such predicate. + const decliner: { declinesStoredRow?(type: string, name: unknown): boolean } = publishedProtocol; + publishedOverlay = typeof decliner.declinesStoredRow === 'function' + && decliner.declinesStoredRow(layered.type, layered.name) + ? layered.effective + : layered.overlay; + } + } catch (overlayError: any) { + // [#5532] The overlay read is NOT blanket-swallowed, + // and this is the second deliberate divergence from + // the dispatcher twin. `getMetaItemLayered` documents + // that it throws `503 SERVICE_UNAVAILABLE` ONLY when a + // read that would decide a layer did not happen; the + // benign "table not provisioned yet" case genuinely + // means "no overlay row" and returns normally with + // `overlay: null`. So a throw here is an availability + // failure, and falling through would let it reach the + // client as `404 Not found` — an availability failure + // reported as an existence fact, which is exactly the + // #5532 defect this package pins in + // `rest-meta-outage-vs-miss.test.ts`. A declared + // status is re-thrown so `handleRouteError` renders + // the producer's own 503; anything undeclared (a + // third-party protocol throwing something shapeless) + // still falls through, so this cannot make the + // code-published path newly fail closed. + if (typeof overlayError?.status === 'number') throw overlayError; + } + } + if (publishedOverlay !== undefined) { + await servePublished(publishedOverlay); + return; + } + + const svc = await this.resolveMetadataService(environmentId, req); + if (typeof (svc as any)?.getPublished !== 'function') { + // [#8297] Reached ONLY when the overlay consult above + // found nothing (a null `layered.overlay`) — so this is + // no longer "this kernel cannot answer /published" (the + // pre-#8278 reading); it is the narrower, rarer + // condition the message below actually states: nothing + // is runtime-published for this item, AND this kernel's + // metadata slot has no code/package store to fall back + // to (`getPublished` is optional on `IMetadataService` + // and unimplemented here). Status, `code`, and routing + // order are unchanged — only the prose was stale. + res.status(501).json({ + error: { + code: 'NOT_IMPLEMENTED', + message: 'Nothing is runtime-published for this item, and this kernel has no code/package store (metadata.getPublished() is not available).', + }, + }); + return; + } + // [commit 26f3588fb] FOLDED here too — the smaller second site + // of the same class. The layered consult above folds + // internally (protocol boundary), but this fallback + // reads the code/package registry, which stores + // CANONICAL types; handed the raw segment it answered + // 404 for a recognised plural and 200 for the singular + // twin of the same code-published item. + const data = withheld + ? undefined + : await (svc as any).getPublished(canonicalMetaUrlType(type), name); + // The 404 this route could never produce before. An + // item that exists but was never published still + // answers 200 with its current definition — that is + // `getPublished`'s documented fallback, and it is a + // different fact from "no such item". + if (data === undefined) { + res.status(404).json({ + error: { code: 'NOT_FOUND', message: 'Not found' }, + }); + return; + } + await servePublished(data); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Get the published version of a metadata item', + tags: ['metadata'], + }, + }); + } +} diff --git a/packages/rest/src/meta-object-state-routes.ts b/packages/rest/src/meta-object-state-routes.ts new file mode 100644 index 00000000000..58cd7759387 --- /dev/null +++ b/packages/rest/src/meta-object-state-routes.ts @@ -0,0 +1,264 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23050 slice 13e] The `/meta` object state-machine read of `RestServer` — + * `GET {metaPath}/object/:name/state/:field`, the legal next states an object + * field's state machine declares (ADR-0020 D3.3) — moved out of + * `rest-server.ts`. It was the seventh group of + * `RestServer#registerMetadataEndpointsInner`'s body, one statement, and that + * method now calls the function below at its old position, so the `/meta` + * routes still register in one order: after the history family, and above the + * published read. + * + * A MOVE, not a redesign. The function below is that statement as it stood, + * re-indented one level: the same route per base, by the same handler, + * answering the same bodies, under no per-family switch (it answers to + * `api.enableMetadata` alone, as the inner method's own docblock says, and + * moving it under a switch is a decision, not a tidy-up). The moved comments + * are moved text too: where one says "here", "below" or "the dispatcher + * branch this mirrors", it still describes `rest-server.ts` and the inner + * method as they were. + * + * ## How the moved body reaches the class + * + * The shape slice 13a set for `/meta` (`meta-collection-routes.ts`' header + * carries the full reasoning). The class keeps the private method, and the + * method keeps its head; at the position of the moved statement it builds one + * host literal ({@link MetaObjectStateRouteHost}) and hands it, with the head's + * one {@link MetaRouteScope}, to the function below as its `this` and its + * argument. + * + * Each member was checked on its own, not inherited from an earlier slice: + * + * - `routeManager` goes BY VALUE, and the literal that carries it is built + * INSIDE `registerMetadataEndpointsInner`, at the position of the statement + * it replaces. That placement is the load-bearing part. This route registers + * on the route manager directly, never through the per-item registrar (it is + * deliberately no member of the per-item face), and the body reads the + * manager only for that one `register` call. The call runs during the inner + * call, while `registerMetadataEndpoints`, the umbrella, has swapped the + * server's manager for its wrapping registrar, and nothing inside the inner + * method reassigns it, so the value captured there is the object that read + * returned: the route still mounts behind the anonymous-deny gate (#3963) + * and the type-level read and write admissions (#21087, #21124) the wrapper + * asks before every `/meta` handler. ⛔ A host built anywhere else — in + * `registerRoutes`, in the constructor, cached on the instance, or in the + * umbrella above the swap — captures the REAL manager, and this route would + * mount outside all three gates. + * - `objectQLProvider` goes as a GETTER. The handler reads that provider FIELD + * at REQUEST time, twice (its presence, then the call, both through + * `wiredEngineOrLoud`), and the field is not `readonly`, so a value copied at + * registration could part from it. The getter reads the field the handler + * read, when it read it: slice 2's batch shape, unbound, per the seat's + * provider ruling (no shipped provider reads `this`). + * - `resolveProtocol` is a METHOD, handed over as a closure that calls it on + * the server at call time, so a test double or spy set on the server is + * still the one the body reaches. + * + * The inner method's head declares the bindings every `/meta` group reads and + * hands them over as ONE parameter, {@link MetaRouteScope}, built once there; + * the function destructures the two its body reads (`metaPath` and + * `isScoped`). The host stays class members only. Every token of the body is + * the one it had: it reaches no private static. + * + * ## Module-level names + * + * `wiredEngineOrLoud`, the engine seam's loud reach, is declared in + * `rest-server.ts`, stays there (the class head and the batch and published + * doors read it too) and is imported from it; it is read only at request + * time, so the two modules importing each other cannot see it uninitialised. + * Every other name is imported from the module `rest-server.ts` imported it + * from. The handler still loads `@objectstack/objectql` through a DYNAMIC + * import inside a `try`: that package is a devDependency here, so a host + * serving REST without the data engine answers `501` instead of failing to + * load, and `scripts/check-undeclared-dep-imports.mjs` ledgers that probe + * against this file. The package entry re-exports nothing from here, and the + * published surface does not move. + */ + +import { handleRouteError } from './error-response.js'; +import type { MetaRouteScope } from './meta-collection-routes.js'; +import * as metaReadGate from './meta-item-read-gate.js'; +import { refuseRepeatedQueryParams } from './query-multiplicity.js'; +import { wiredEngineOrLoud, type RestServer } from './rest-server.js'; + +/** + * The `RestServer` members {@link registerMetaObjectStateRoute} reads — every + * one of them, and no other, in the order the body first reads them. Each is + * typed AS the class member (an indexed access on the class), so a change to a + * member's signature on the class is a change here too, and naming a member the + * class does not have is a compile error. The module header says why + * `routeManager` is captured inside the swap and by value, and why the provider + * field is a getter. + */ +export interface MetaObjectStateRouteHost { + readonly routeManager: RestServer['routeManager']; + readonly objectQLProvider: RestServer['objectQLProvider']; + resolveProtocol: RestServer['resolveProtocol']; +} + +/** + * Register the `/meta` object state-machine read — the seventh group of + * `RestServer#registerMetadataEndpointsInner`' body, called by that method at + * its position in its registration order, with the server's members as `this` + * and the method's head bindings as `scope`. The method's own docblock, which + * the published declaration carries, names the families, their switches and + * why this route answers to none of them. + */ +export function registerMetaObjectStateRoute(this: MetaObjectStateRouteHost, scope: MetaRouteScope): void { + const { metaPath, isScoped } = scope; + + // 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`) + // and implemented in the dispatcher's `/meta` branch — but REST's ~17 + // `/meta` routes topped out at THREE path segments and this one needs + // 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. + // + // [commit 7986d973f] The four-segment collision this comment used to describe is + // GONE with the compound `/:type/:section/:name/published` twin. That + // twin captured `/meta/object/x/state/published` as "the published + // version of the compound name object/x/state", and only the literal + // `object`/`state` segments winning kept the FSM reading — a field + // literally named `published` was the ambiguity. With the twin retired + // no other route matches four segments, so this mount is now the only + // reading of that path rather than the preferred one. + 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. + // [#23214] The list lives beside the shared decision, so the + // runtime dispatcher's twin refuses the same name. + if (refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS.state)) return; + const from = req.query?.from !== undefined ? String(req.query.from) : undefined; + // [#15405] The engine seam, reached the way its SIBLING at + // `computeExecCtx` already reaches it — `wiredEngineOrLoud` + // — so "no engine is wired" and "the engine WAS wired and + // could not be resolved" stay two facts instead of one + // `undefined`. The retired spelling this replaces: + // + // this.objectQLProvider(environmentId).catch(() => undefined) + // + // ⚠️ That `.catch` was DEAD CODE until #13904. The shipped + // provider used to be `try { … } catch { return undefined; }` + // and so could not reject at all; the collapse happened one + // layer earlier. #13904 made the provider re-raise PRECISELY + // so a consumer could see the outage — and this consumer, the + // slot's second and the one nobody enumerated, caught it + // straight back. A wired-and-failing engine and a + // never-registered one therefore both answered the + // `404 NOT_FOUND · "Object not found"` twelve lines below: + // this route lying about the cause during exactly the + // incident it would be consulted in. + // + // ⛔ NOT `seamOrUndefined`. That helper SWALLOWS, and + // swallowing at this seam IS the defect #13476 repaired — + // its own docblock forbids routing the data-engine seam + // back through it "to make the seams uniform". + // + // The wiring fact is the provider's PRESENCE, asked once and + // never inferred from what it returned, so an UNWIRED engine + // still reaches the 404 below byte-for-byte as before — and + // so does a provider that RESOLVES `undefined`, which is the + // seam contract declaring absence rather than failing. + // `wiredEngineOrLoud` also invokes the provider + // SYNCHRONOUSLY, so a host wiring a non-`async` provider — + // which the seam's declared type cannot prevent — reaches the + // same answer as one that rejects (commit add6a1b1c) instead of + // escaping past a `.catch` that never came into existence. + const ql = await wiredEngineOrLoud( + Boolean(this.objectQLProvider), + () => this.objectQLProvider!(environmentId), + ); + // [#7557, #24210] The registry is a resolution reader and + // keeps serving a disabled package's object, so this API + // door asks first and answers what `GET /meta/object/:name` + // answers: the protocol's own refusal, `404 + // OBJECT_PACKAGE_DISABLED` (the dispatcher twin's + // `disabledObjectAnswer`); any other throw of that read is + // answered as itself, never as an absence. A protocol that + // does not refuse it gets this door's own answer for no + // object, below. The schema is never read for such a name. + const registry = (ql as any)?.registry; + const disabledObject = typeof registry?.isObjectPackageDisabled === 'function' + && registry.isObjectPackageDisabled(name) === true; + if (disabledObject) { + const p = await this.resolveProtocol(environmentId, req); + await p.getMetaItem({ type: 'object', name }); + } + const schema = disabledObject ? undefined : 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; + } + // Three answer values — `next: null`, `next: []` (a + // declared dead end), and the legal-next list — the same + // answer the dispatcher gives, because a UI asking "where + // can this record go" must tell those apart. But `null` + // is overloaded across TWO input conditions: no FSM + // governs the field, or the caller omitted `?from=` (no + // `from` => no transition table to answer with), which + // the line below folds onto the same `null` without + // consulting the rule. A UI therefore cannot read `null` + // as "no state machine" unless it passed a `from`. + 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'], + }, + }); +} diff --git a/packages/rest/src/meta-state-route-engine-outage.test.ts b/packages/rest/src/meta-state-route-engine-outage.test.ts index 92e5aac5076..da50e9ec8e7 100644 --- a/packages/rest/src/meta-state-route-engine-outage.test.ts +++ b/packages/rest/src/meta-state-route-engine-outage.test.ts @@ -105,7 +105,8 @@ // never inside a clocked window. // // The route reaches `legalNextStates` through a dynamic -// `await import('@objectstack/objectql')` in `rest-server.ts`, kept dynamic on +// `await import('@objectstack/objectql')` in `meta-object-state-routes.ts` (in +// `rest-server.ts` until #23050 slice 13e moved the route), kept dynamic on // purpose (a devDependency there: a host without the data engine degrades to // 501 rather than failing to load). This package's tests resolve that specifier // through `dist/`, so the first request to reach that line pays the vite @@ -130,7 +131,7 @@ // raised timeout: either only moves the window around the cost, and // `dev-plugin-security-enforcement-warning.test.ts` (plugin-dev) records such a // hook budget being exhausted on a heavier shard. The dynamic call in -// `rest-server.ts` stays where it is; this only decides where the first load is +// `meta-object-state-routes.ts` stays where it is; this only decides where the first load is // paid. §0's multi-kernel case pins it with a budget on its own work. import '@objectstack/objectql'; diff --git a/packages/rest/src/objectql-slot-consumer-census.test.ts b/packages/rest/src/objectql-slot-consumer-census.test.ts index ae86aa84a13..a519180a4a6 100644 --- a/packages/rest/src/objectql-slot-consumer-census.test.ts +++ b/packages/rest/src/objectql-slot-consumer-census.test.ts @@ -138,11 +138,31 @@ const EMAIL_SOURCE = readFileSync(resolve(HERE, 'email-routes.ts'), 'utf8'); * literal the class hands it, whose `objectQLProvider` is a getter onto the * class's own field: so it is still this slot's consumer, and still this * census's population. A site is identified by `file:line`, never by a line alone. + * + * [#23050 slice 13e] Two more consumers moved the same way: + * `GET /meta/object/:name/state/:field` (the SECOND consumer, #15405's) to + * `meta-object-state-routes.ts`, and `GET /meta/:type/:name/published` (the + * FOURTH) to `meta-item-history-routes.ts`. Each still reads + * `this.objectQLProvider`, off a host literal the inner `/meta` method hands it, + * whose `objectQLProvider` is a getter onto the class's own field. The two left + * in `rest-server.ts` are the authorization-input seam and the layered chain's + * owner step, both in the class head. */ -const SLOT_FILES = ['rest-server.ts', 'batch-routes.ts'] as const; +const SLOT_FILES = [ + 'rest-server.ts', + 'batch-routes.ts', + 'meta-object-state-routes.ts', + 'meta-item-history-routes.ts', +] as const; const SOURCES = new Map(SLOT_FILES.map((file) => [ file, file === 'rest-server.ts' ? SOURCE : readFileSync(resolve(HERE, file), 'utf8'), ])); +/** + * [#23050 slice 13e] The state-machine read's module: its retired-spelling + * comment, which quotes this file's subject, is what §1's comment-dropping + * control reads. + */ +const STATE_SOURCE = SOURCES.get('meta-object-state-routes.ts')!; /** Every invocation site of the slot over every censused file, keyed `file:line`. */ function censusInvocationSites(field: string): Array<{ file: string; line: number; text: string; lines: string[] }> { @@ -192,16 +212,23 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () // this file replaces. expect(readSites(SOURCE, 'objectQLProviderThatDoesNotExist')).toEqual([]); // And the scanner really does drop comment lines: this file's own - // subject is quoted inside a retired-spelling comment in `rest-server.ts`. - expect(SOURCE).toContain('this.objectQLProvider(environmentId).catch(() => undefined)'); - expect(invocationSites(SOURCE, 'objectQLProvider').some((s) => s.text.startsWith('//'))).toBe(false); + // subject is quoted inside a retired-spelling comment in the state-machine + // read's body. [#23050 slice 13e] That body moved to + // `meta-object-state-routes.ts`, so both halves read it there. + expect(STATE_SOURCE).toContain('this.objectQLProvider(environmentId).catch(() => undefined)'); + expect(invocationSites(STATE_SOURCE, 'objectQLProvider').length).toBeGreaterThan(0); + expect(invocationSites(STATE_SOURCE, 'objectQLProvider').some((s) => s.text.startsWith('//'))).toBe(false); }); it('⭐ FIVE consumers: three, not two, was the count the two prose censuses got wrong, and #24210 adds /published and the layered view', () => { // [#23050 slice 2] 3 UNCHANGED, over two files: 2 in `rest-server.ts` // and the batch door's 1 in `batch-routes.ts`. [#24210] 4: `/published`'s // disabled-owner check joins `rest-server.ts`; 5: the layered chain's - // owner step (`metaItemReadGateSources`) joins it too. + // owner step (`metaItemReadGateSources`) joins it too. [#23050 slice 13e] + // 5 UNCHANGED, over four files: 2 in `rest-server.ts` (the class head's + // two), the batch door's 1, the state-machine read's 1 in + // `meta-object-state-routes.ts` and `/published`'s 1 in + // `meta-item-history-routes.ts`. const sites = censusInvocationSites('objectQLProvider'); // The message carries the sites, so a failure here hands the next // reader the new census instead of a bare number. @@ -260,6 +287,8 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () // host interface's member, typed `RestServer['objectQLProvider']`. That // one spelling is set aside before the check and pinned at exactly one // there (zero here), so setting it aside cannot grow into an exemption. + // [#23050 slice 13e] The same for the two `/meta` modules: each spells it + // once, in the one host interface of its own that hands the slot on. const HOST_MEMBER_TYPE = "RestServer['objectQLProvider']"; for (const [file, source] of SOURCES) { expect(source.split(HOST_MEMBER_TYPE).length - 1, file).toBe(file === 'rest-server.ts' ? 0 : 1); diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index 4af1a4bf9c4..d6cf5fa69bc 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -193,6 +193,11 @@ interface CensusEntry { * [#23050 slice 13d] 4 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0 + 0 + 0, * unchanged: the history family (`meta-item-history-routes.ts`) builds no * `query:` slot either, so the moved module's floor is 0. + * + * [#23050 slice 13e] 4 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0 + 0 + 0 + 0, + * unchanged: neither the object state-machine read (`meta-object-state-routes.ts`) + * nor the published read (now in `meta-item-history-routes.ts`) builds a `query:` + * slot, so the new module's floor is 0 and the history module's stays 0. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 0, @@ -212,6 +217,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // carry no slot). // [#23050 slice 13d] Length 16 → 17, the sum unchanged (the history family // carries no slot). - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); + // [#23050 slice 13e] Length 17 → 18, the sum unchanged (neither the object + // state-machine read nor the published read carries a slot). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(18); expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); diff --git a/packages/rest/src/rest-server-docblock-position.test.ts b/packages/rest/src/rest-server-docblock-position.test.ts index cab97959dee..83c3499b539 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -221,6 +221,14 @@ describe('rest-server.ts docblock position', () => { * and took no module-level helper with it (the authoring-door refusal it calls * stays, exported, and only gained a paragraph in its own block), so * `rest-server.ts` keeps every block it had (161). + * + * [#23050 slice 13e] `meta-object-state-routes.ts` carries three: the module + * header, the host interface's block and the function's. The published read + * joined `meta-item-history-routes.ts` with two more, its own host interface's + * block and its function's (3 → 5). Neither moved group held a TSDoc block of + * its own or took a module-level helper with it, and the inner head's + * per-item-registrar block was only re-worded, so `rest-server.ts` keeps every + * block it had (161). */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4, @@ -238,7 +246,8 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -283,9 +292,12 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // over fifteen, length 15 → 16. // [#23050 slice 13d] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 3 + 3 + 3 + 5 + 5 + 3 + 3 = 65 // over sixteen, length 16 → 17. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); + // [#23050 slice 13e] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 3 + 3 + 3 + 5 + 5 + 3 + 5 + 3 = 70 + // over seventeen, length 17 → 18 (`meta-item-history-routes.ts` 3 → 5: the + // published read's host interface and function). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(18); expect(Object.keys(MOVED_FILE_FLOORS).sort()) .toEqual(REST_SERVER_REGISTRAR_FILES.filter((file) => file !== FILE).sort()); - expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(65); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(70); }); }); diff --git a/packages/rest/src/rest-server-query-number-census.test.ts b/packages/rest/src/rest-server-query-number-census.test.ts index 73a5780926f..73dcb1c4d64 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -356,7 +356,12 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi // block: the rollback's `Number(toVersionRaw)`, an exemption row. Its key names // the route, never the file, so no ledger row changed: // 9 = 7 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1, length 16 → 17. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); + // [#23050 slice 13e] Nor do the object state-machine read + // (`meta-object-state-routes.ts`) and the published read (now in + // `meta-item-history-routes.ts`): both read their query as strings and coerce + // no number of it: + // 9 = 7 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0, length 17 → 18. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(18); expect(SOURCES.map(([file]) => file)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(sites).toHaveLength(9); }); diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index 3f740db312e..fa939290f54 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -63,8 +63,12 @@ export const REST_SERVER_REGISTRAR_FILES = [ // body, the per-item writes — see {@link REST_SERVER_META_ROUTE_FILES}. 'meta-item-write-routes.ts', // [#23050 slice 13d] The sixth `/meta` group of `registerMetadataEndpointsInner`' - // body, the history family — see {@link REST_SERVER_META_ROUTE_FILES}. + // body, the history family — see {@link REST_SERVER_META_ROUTE_FILES}. [#23050 slice + // 13e] It holds the eighth and last too, the published read, as a second registrar. 'meta-item-history-routes.ts', + // [#23050 slice 13e] The seventh `/meta` group of `registerMetadataEndpointsInner`' + // body, the object state-machine read — see {@link REST_SERVER_META_ROUTE_FILES}. + 'meta-object-state-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ @@ -84,7 +88,9 @@ export type RestServerRegistrarFile = (typeof REST_SERVER_REGISTRAR_FILES)[numbe * * Named file by file, ⛔ never by a naming convention: a module joins this * subset in the slice that moves a `/meta` group into it, and a reader that - * depends on it pins it against the module calls inside the inner method. + * depends on it pins it against the module calls inside the inner method (one + * per file, and two for `meta-item-history-routes.ts`, the one module the inner + * method calls at two positions). */ export const REST_SERVER_META_ROUTE_FILES: readonly RestServerRegistrarFile[] = [ // [#23050 slice 13a] The type list, the whole-store maintenance family and the @@ -99,8 +105,14 @@ export const REST_SERVER_META_ROUTE_FILES: readonly RestServerRegistrarFile[] = 'meta-item-write-routes.ts', // [#23050 slice 13d] The history family: `/history`, `/audit`, `/publish`, // `/rollback` and `/diff`. Like the per-item writes, all five routes go through the - // inner method's per-item registrar, handed over in its scope. + // inner method's per-item registrar, handed over in its scope. [#23050 slice 13e] + // The published read joined them, the same way, through a second function the inner + // method calls at its own position: the one module here it calls twice. 'meta-item-history-routes.ts', + // [#23050 slice 13e] The object state-machine read, `GET {metaPath}/object/:name/state/:field`. + // It is in no per-family switch and registers on the route manager itself, by value + // from a host literal built inside the inner method. + 'meta-object-state-routes.ts', ]; /** diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index ad320586540..ee2714e674d 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -57,7 +57,8 @@ import { applyObjectSchemaMask, isObjectSchemaMaskExempt, isObjectSchemaMaskingEnabled, - relateObjectSchemaMaskPosture, + // `relateObjectSchemaMaskPosture`, read only by the published read's mask, moved with that + // door to `meta-item-history-routes.ts` (#23050 slice 13e). resolveObjectSchemaMaskPosture, OBJECT_SCHEMA_MASK_NOT_APPLICABLE, // `foldVisibilityFingerprintIntoEtag`, `normalizeIfNoneMatch` and @@ -103,10 +104,9 @@ import { RouteManager, type RouteEntry } from './route-manager.js'; // `assertFilterParamSuppliedOnce` (#7390), the `/data` list's filter-slot arity rule, read only // by that door, moved with `registerCrudEndpoints`' body to `crud-routes.ts` (#23050 slice 12). import { refuseRepeatedQueryParams } from './query-multiplicity.js'; -// [#7527] The other half of the same discipline: a parameter this route does -// not KNOW is refused rather than dropped. See `query-allowlist.ts` for why an -// ignored filter is the one wrong answer a caller cannot detect. -import { refuseUnknownQueryParams } from './query-allowlist.js'; +// `refuseUnknownQueryParams`, the other half of the same discipline (a parameter a route does +// not KNOW is refused rather than dropped; see `query-allowlist.ts`), read only by the +// published read, moved with that door to `meta-item-history-routes.ts` (#23050 slice 13e). import type { DirectMountedRoute, MountedRouteSource } from './direct-mount.js'; import { RestServerConfig, RestApiConfigParsed } from '@objectstack/spec/api'; // `standardErrorCodeForHttpStatus` (#11683), the catalog's floor for a required `code`, read @@ -167,7 +167,9 @@ import type { // doors (#6994/#7095) refuse. // `PUBLIC_FORM_SERVER_MANAGED_FIELDS`, read only by the anonymous form doors, moved with // `registerFormEndpoints`' body to `form-routes.ts` (#23050 slice 11). -import { PLURAL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/shared'; +// `canonicalMetaUrlType`, read only by the published read's code-store fallback, moved with +// that door to `meta-item-history-routes.ts` (#23050 slice 13e). +import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; // `stripReadDecorations`, read only by the analytics dataset door, moved with that door to // `analytics-routes.ts` (#23050 slice 5). // [#20193] THE per-caller read gate of a `/meta/:type/:name` document, and the @@ -211,8 +213,9 @@ import { // `meta-item-read-routes.ts` (#23050 slice 13b); the re-export below still names it. // `META_ITEM_SAVE_PARAMS` is read only by the item save, which moved to // `meta-item-write-routes.ts` (#23050 slice 13c); the re-export below still names it. + // `META_ITEM_PUBLISHED_PARAMS` is read only by the `/published` door, which moved to + // `meta-item-history-routes.ts` (#23050 slice 13e); the re-export below still names it. META_ITEM_MEMBER_PARAM, - META_ITEM_PUBLISHED_PARAMS, // `metaMemberSelector`, the member selector's one reading, is read only by the history // family's `?member=` doors, which moved to `meta-item-history-routes.ts` (#23050 // slice 13d). @@ -331,7 +334,16 @@ import { registerMetaItemReadRoutes, type MetaItemReadRouteHost } from './meta-i import { registerMetaItemWriteRoutes, type MetaItemWriteRouteHost } from './meta-item-write-routes.js'; // [#23050 slice 13d] The `/meta` history family (the change log, the audit trail, the // publish, the rollback and the version comparison) moved to its own module — see its header. -import { registerMetaItemHistoryRoutes, type MetaItemHistoryRouteHost } from './meta-item-history-routes.js'; +// [#23050 slice 13e] The published read joined it there, as that module's second function. +import { + registerMetaItemHistoryRoutes, + registerMetaItemPublishedRoute, + type MetaItemHistoryRouteHost, + type MetaItemPublishedRouteHost, +} from './meta-item-history-routes.js'; +// [#23050 slice 13e] The `/meta` object state-machine read moved to its own module — see its +// header. +import { registerMetaObjectStateRoute, type MetaObjectStateRouteHost } from './meta-object-state-routes.js'; import { loadExcelJs, type Worksheet } from './xlsx-module.js'; // [#18386] `?template=true` on the export door: the import template's column // rule, request reading and workbook. See the module header. @@ -372,7 +384,9 @@ import { // `registerOpenApiEndpoints`' body to `discovery-routes.ts` (#23050 slice 7). sendDeclaredFault, sendFieldVisibilityFault, - handleRouteError, + // `handleRouteError`, read only by the object state-machine read's and the published read's + // catches, moved with those doors to `meta-object-state-routes.ts` and + // `meta-item-history-routes.ts` (#23050 slice 13e). // `thrownAnswerIsBareNotFound`: the item read's catch moved to `meta-item-read-routes.ts` // (#23050 slice 13b) and the diff door's to `meta-item-history-routes.ts` (#23050 // slice 13d); the event doors' refusal here still reads it (#24210). @@ -5153,15 +5167,18 @@ export class RestServer { * face: `GET` / `PUT` / `DELETE {metaPath}/:type/:name`, the * `/references` and `/layers` reads, the history family (`/history`, * `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) and the - * book tree. The first four of those are inside the `if` block further - * down; every later member goes through this call. + * book tree. The first four of those are inside the `if` block that + * moved with them to `meta-item-read-routes.ts`; every later member + * goes through this call, which reaches `meta-item-write-routes.ts` and + * `meta-item-history-routes.ts` in `scope` (#23050 slices 13b–13e). * * A call rather than one more `if` block, for two reasons that are not * cosmetic: * 1. **No single brace pair contains exactly the right set.** The - * later members are spread across ~1200 lines with - * `GET {metaPath}/object/:name/state/:field` — deliberately NOT - * part of this face — sitting among them. + * later members are registered by three calls below, in two + * modules, with `GET {metaPath}/object/:name/state/:field` — + * deliberately NOT part of this face — registered between the last + * two of them. * 2. **A gate that travels with its registration cannot be inherited * or shed by moving a route past a brace**, which is exactly how * this switch came to gate four reads and none of its own writes: @@ -5284,442 +5301,46 @@ export class RestServer { }; registerMetaItemHistoryRoutes.call(itemHistoryHost, scope); - // 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`) - // and implemented in the dispatcher's `/meta` branch — but REST's ~17 - // `/meta` routes topped out at THREE path segments and this one needs - // 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. - // - // [commit 7986d973f] The four-segment collision this comment used to describe is - // GONE with the compound `/:type/:section/:name/published` twin. That - // twin captured `/meta/object/x/state/published` as "the published - // version of the compound name object/x/state", and only the literal - // `object`/`state` segments winning kept the FSM reading — a field - // literally named `published` was the ambiguity. With the twin retired - // no other route matches four segments, so this mount is now the only - // reading of that path rather than the preferred one. - 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. - // [#23214] The list lives beside the shared decision, so the - // runtime dispatcher's twin refuses the same name. - if (refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS.state)) return; - const from = req.query?.from !== undefined ? String(req.query.from) : undefined; - // [#15405] The engine seam, reached the way its SIBLING at - // `computeExecCtx` already reaches it — `wiredEngineOrLoud` - // — so "no engine is wired" and "the engine WAS wired and - // could not be resolved" stay two facts instead of one - // `undefined`. The retired spelling this replaces: - // - // this.objectQLProvider(environmentId).catch(() => undefined) - // - // ⚠️ That `.catch` was DEAD CODE until #13904. The shipped - // provider used to be `try { … } catch { return undefined; }` - // and so could not reject at all; the collapse happened one - // layer earlier. #13904 made the provider re-raise PRECISELY - // so a consumer could see the outage — and this consumer, the - // slot's second and the one nobody enumerated, caught it - // straight back. A wired-and-failing engine and a - // never-registered one therefore both answered the - // `404 NOT_FOUND · "Object not found"` twelve lines below: - // this route lying about the cause during exactly the - // incident it would be consulted in. - // - // ⛔ NOT `seamOrUndefined`. That helper SWALLOWS, and - // swallowing at this seam IS the defect #13476 repaired — - // its own docblock forbids routing the data-engine seam - // back through it "to make the seams uniform". - // - // The wiring fact is the provider's PRESENCE, asked once and - // never inferred from what it returned, so an UNWIRED engine - // still reaches the 404 below byte-for-byte as before — and - // so does a provider that RESOLVES `undefined`, which is the - // seam contract declaring absence rather than failing. - // `wiredEngineOrLoud` also invokes the provider - // SYNCHRONOUSLY, so a host wiring a non-`async` provider — - // which the seam's declared type cannot prevent — reaches the - // same answer as one that rejects (commit add6a1b1c) instead of - // escaping past a `.catch` that never came into existence. - const ql = await wiredEngineOrLoud( - Boolean(this.objectQLProvider), - () => this.objectQLProvider!(environmentId), - ); - // [#7557, #24210] The registry is a resolution reader and - // keeps serving a disabled package's object, so this API - // door asks first and answers what `GET /meta/object/:name` - // answers: the protocol's own refusal, `404 - // OBJECT_PACKAGE_DISABLED` (the dispatcher twin's - // `disabledObjectAnswer`); any other throw of that read is - // answered as itself, never as an absence. A protocol that - // does not refuse it gets this door's own answer for no - // object, below. The schema is never read for such a name. - const registry = (ql as any)?.registry; - const disabledObject = typeof registry?.isObjectPackageDisabled === 'function' - && registry.isObjectPackageDisabled(name) === true; - if (disabledObject) { - const p = await this.resolveProtocol(environmentId, req); - await p.getMetaItem({ type: 'object', name }); - } - const schema = disabledObject ? undefined : 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; - } - // Three answer values — `next: null`, `next: []` (a - // declared dead end), and the legal-next list — the same - // answer the dispatcher gives, because a UI asking "where - // can this record go" must tell those apart. But `null` - // is overloaded across TWO input conditions: no FSM - // governs the field, or the caller omitted `?from=` (no - // `from` => no transition table to answer with), which - // the line below folds onto the same `null` without - // consulting the rule. A UI therefore cannot read `null` - // as "no state machine" unless it passed a `from`. - 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] - // - // Ledgered since #3563 (`meta.getPublished`) and implemented in the - // dispatcher, but never mounted here — so the request fell into the - // compound-name route below with `section=:name, name='published'`, - // which answered a protection-envelope stub. Identical before and - // after publish, identical for a name that does not exist: a route - // that structurally could not 404. - // - // ONE arity since commit 7986d973f (stage 3 of the maintainer-ruled - // retirement of compound-name addressing, 2026-08-25). This route used - // to be mounted twice — the second registration was - // `/:type/:section/:name/published`, folding `section` and `name` back - // into one slash-bearing key so the SDK's - // `getPublished('lead', 'views/all_leads')` could reach it. - // - // Stage 1 (commit 311433f6b) declared the item-name grammar and refuses every - // slash-bearing name at the publish door, so no name reachable ONLY - // through that arity can exist any more. What remains addressable is a - // pre-grammar residue row, and it is reachable HERE: a percent-encoded - // `%2F` matches this single-segment pattern and Hono decodes the - // parameter back to `views/all_leads` (measured, not assumed), which is - // the spelling the SDK now sends for every name. So the compound arity - // was removed WITHOUT removing the capability — D1's "any stored junk - // name remains listable and clearable" still holds through this door. - { - registerPerItemRoute({ - method: 'GET', - path: `${metaPath}/:type/:name/published`, - handler: async (req: any, res: any) => { - try { - // [#23072] Recognition first, over the measured closed - // set — empty: this door reads no query parameter. - if (refuseUnknownQueryParams(req, res, META_ITEM_PUBLISHED_PARAMS)) return; - const environmentId = isScoped ? req.params?.environmentId : undefined; - const type = String(req.params?.type ?? ''); - // [commit 7986d973f] No `section` fold: this route has one arity. - // A percent-encoded slash arrives already decoded here, - // so a residue name reads exactly as it is stored. - const name = String(req.params?.name ?? ''); - // [#8278] The AUTHORITATIVE published store is consulted - // first: the `state:'active'` `sys_metadata` overlay row. - // Mirrors the dispatcher fix (#8031 / PR #8254, - // `packages/runtime/src/domains/meta.ts`) onto the - // transport that actually serves the cloud runtime — the - // two doors answered the same question from two stores, - // and only one of them had been corrected. - // - // Two publish lifecycles write to two different places: - // - // - `MetadataManager.publishPackage` snapshots a body - // into the row-local `publishedDefinition` key of its - // own in-memory registry — the ADR-0016-era package - // publish, which is what `getPublished` below reads. - // - `publishPackageDrafts` / `promoteDraft` flips the - // artifact's `sys_metadata` row `state:'draft' → - // 'active'`. ADR-0027 (E)(5) defines sealing a publish - // as exactly that flip, `SysMetadataRepository` names - // `'active'` "the published, live overlay", and - // ADR-0033 §2 routes EVERY runtime authoring write - // into that same ADR-0027 draft — so promoting it is - // what "published" means for anything authored at - // runtime. Those items were absent from the registry - // `getPublished` consults, so this route answered 404 - // about an item that IS published. - // - // `getMetaItemLayered` is the narrow primitive on purpose. - // Its overlay layer is a strict `state:'active'` lookup - // that never reads a draft, and it reports that layer - // SEPARATELY from the code layer — so a null overlay is - // positively "no runtime-published row" and falls through - // to the untouched `getPublished` path below, which keeps a - // code-published item resolving to byte-identical bytes. - // The broader `getMetaItem` would not do: it folds the code - // layer into its own answer, so this route could no longer - // tell the two stores apart. - // - // [ADR-0131 D6] NO `organizationId`: the per-organization - // overlay axis is retired, so every publish this - // transport produces lands environment-wide, and this - // door resolves exactly those. A legacy - // organization-scoped row is not served; its promotion is - // ADR-0131 C7's. - // - // Environment scoping still holds: it comes from WHICH - // protocol `resolveProtocol` hands back, not from the - // request payload (`getMetaItemLayered` declares no - // `environmentId` member). - // [#20156] This door serves ONE document, the same - // representation the plain read serves — so it answers - // exactly what the plain read answers this caller: every - // gate arm (`all`), a partly-withheld app PRUNED, and the - // ADR-0106 object mask, its posture resolved BEFORE the - // fetch (D2/D3's `fetch → mask → send`). It ran none of - // them, so a member the plain read refuses - // `crm_admin_runbook` read its body here, and an object's - // unreadable fields were served whole. Both exits below - // send through `servePublished`. - const publishedMetaType = RestServer.metaTypeSingular(type); - let publishedMaskPosture: ObjectSchemaMaskPosture; - try { - publishedMaskPosture = await (await this.resolveObjectMasker(environmentId, req, publishedMetaType))(name); - } catch (maskError: any) { - if (maskError instanceof ObjectSchemaMaskEvaluationError) { - sendFieldVisibilityFault(res, name); - return; - } - throw maskError; - } - let publishedProtocol: any; - try { - publishedProtocol = await this.resolveProtocol(environmentId, req); - } catch { /* fall through to the code/package snapshot below */ } - const servePublished = async (document: any): Promise => { - // The gate reads the protocol for its inputs (books, - // the doc corpus, the object registry). The swallowed - // resolution above is a fall-through for the SNAPSHOT; - // a gate input is never read as absent, so a type the - // gate judges resolves it again and lets it throw. - const gateProtocol: RestProtocol = publishedProtocol - ?? await this.resolveProtocol(environmentId, req); - const verdict = await this.metaItemReadGate( - environmentId, req, gateProtocol, publishedMetaType, name, [document], - { arms: 'all', app: 'gate' }, - )(document); - if (verdict.kind === 'refuse') { - verdict.send(res); - return; - } - let served = verdict.document; - if (publishedMaskPosture.kind === 'project') { - const related = await relateObjectSchemaMaskPosture(publishedMaskPosture, served); // [#21884] - const masked = this.maskObjectDocument(res, related, name, served); - if (!masked) return; - served = masked.document; - } else if (publishedMaskPosture.kind === 'undetermined') { - res.header('Cache-Control', 'private, no-store'); - } - res.json(served); - }; - // [#20156] The overlay found is SERVED outside the - // `try` below: that `catch` classifies a failed overlay - // READ, and a gate fault raised while serving must reach - // `handleRouteError` as itself — never fall through to - // the snapshot as though no overlay existed. - // [#7557, #24210] A disabled package's item is answered - // as a name with nothing behind it, as the plain read - // and the list answer it. Neither store below knows a - // disable (the layered read keeps serving its resolution - // reader, and the snapshot carries no owner), so the - // owner is the one the plain read's kept protection - // envelope names, `packageId`, asked of the registry's - // `isPackageDisabled`: the predicate the protocol asks of - // that same field. An object is the layered read's own - // refusal below; `package` is never hidden. - let withheld = false; - if (publishedMetaType !== 'object' && publishedMetaType !== 'package' - && typeof publishedProtocol?.getMetaItem === 'function') { - const ql = await wiredEngineOrLoud( - Boolean(this.objectQLProvider), - () => this.objectQLProvider!(environmentId), - ); - const registry = (ql as any)?.registry; - if (typeof registry?.isPackageDisabled === 'function') { - const envelope = await publishedProtocol.getMetaItem({ type, name }); - withheld = registry.isPackageDisabled(envelope?.packageId) === true; - } - } - let publishedOverlay: unknown; - if (!withheld && typeof publishedProtocol?.getMetaItemLayered === 'function') { - try { - const layered = await publishedProtocol.getMetaItemLayered({ - type, - name, - }); - if (layered?.overlay !== undefined && layered?.overlay !== null) { - // [#21002, #21986, ADR-0126 §2, ADR-0062 D4] - // When the layered read put a code layer - // over this stored row, this door serves - // that effective layer, not the row. The - // protocol decides it with one predicate, - // `declinesStoredRow`, for both name - // classes: a shipped flow name (the - // loader's body; `flow` is Regime C, - // "never an overlay read path") and a - // code-defined datasource name (the code - // definition; "code wins on collision"). - // The predicate is ASKED of its owner with - // the answer's own `type` / `name`, never - // re-derived here, so this door, the - // by-name read, the list and - // `getMetaItemLayered` read one rule. Every - // other stored row is served exactly as - // before — an `object` too, whose effective - // layer differs from its row by folding, not - // by this decision — and so is every row of a - // protocol that brings no such predicate. - const decliner: { declinesStoredRow?(type: string, name: unknown): boolean } = publishedProtocol; - publishedOverlay = typeof decliner.declinesStoredRow === 'function' - && decliner.declinesStoredRow(layered.type, layered.name) - ? layered.effective - : layered.overlay; - } - } catch (overlayError: any) { - // [#5532] The overlay read is NOT blanket-swallowed, - // and this is the second deliberate divergence from - // the dispatcher twin. `getMetaItemLayered` documents - // that it throws `503 SERVICE_UNAVAILABLE` ONLY when a - // read that would decide a layer did not happen; the - // benign "table not provisioned yet" case genuinely - // means "no overlay row" and returns normally with - // `overlay: null`. So a throw here is an availability - // failure, and falling through would let it reach the - // client as `404 Not found` — an availability failure - // reported as an existence fact, which is exactly the - // #5532 defect this package pins in - // `rest-meta-outage-vs-miss.test.ts`. A declared - // status is re-thrown so `handleRouteError` renders - // the producer's own 503; anything undeclared (a - // third-party protocol throwing something shapeless) - // still falls through, so this cannot make the - // code-published path newly fail closed. - if (typeof overlayError?.status === 'number') throw overlayError; - } - } - if (publishedOverlay !== undefined) { - await servePublished(publishedOverlay); - return; - } + // [#23050 slice 13e] Both host literals below hand on the class's engine provider + // FIELD, which both moved doors read at request time, as a getter onto this server: + // the batch registrar's shape. This one arrow is what both getters read. + const readObjectQLProvider = () => this.objectQLProvider; - const svc = await this.resolveMetadataService(environmentId, req); - if (typeof (svc as any)?.getPublished !== 'function') { - // [#8297] Reached ONLY when the overlay consult above - // found nothing (a null `layered.overlay`) — so this is - // no longer "this kernel cannot answer /published" (the - // pre-#8278 reading); it is the narrower, rarer - // condition the message below actually states: nothing - // is runtime-published for this item, AND this kernel's - // metadata slot has no code/package store to fall back - // to (`getPublished` is optional on `IMetadataService` - // and unimplemented here). Status, `code`, and routing - // order are unchanged — only the prose was stale. - res.status(501).json({ - error: { - code: 'NOT_IMPLEMENTED', - message: 'Nothing is runtime-published for this item, and this kernel has no code/package store (metadata.getPublished() is not available).', - }, - }); - return; - } - // [commit 26f3588fb] FOLDED here too — the smaller second site - // of the same class. The layered consult above folds - // internally (protocol boundary), but this fallback - // reads the code/package registry, which stores - // CANONICAL types; handed the raw segment it answered - // 404 for a recognised plural and 200 for the singular - // twin of the same code-published item. - const data = withheld - ? undefined - : await (svc as any).getPublished(canonicalMetaUrlType(type), name); - // The 404 this route could never produce before. An - // item that exists but was never published still - // answers 200 with its current definition — that is - // `getPublished`'s documented fallback, and it is a - // different fact from "no such item". - if (data === undefined) { - res.status(404).json({ - error: { code: 'NOT_FOUND', message: 'Not found' }, - }); - return; - } - await servePublished(data); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'Get the published version of a metadata item', - tags: ['metadata'], - }, - }); - } + // [#23050 slice 13e] The object state-machine read + // (`GET {metaPath}/object/:name/state/:field`) moved to `meta-object-state-routes.ts`, + // and is registered HERE so it keeps its place in the order: after the history family, + // above the published read below. It is in no per-family switch and registers on the + // route manager itself, so this literal hands that body the route manager by value + // (see `MetaObjectStateRouteHost`), built inside this method for the same reason as + // the first one above: the manager it captures is the umbrella's wrapping registrar. + // Read that module's header before moving it. + const objectStateHost: MetaObjectStateRouteHost = { + routeManager: this.routeManager, + get objectQLProvider() { return readObjectQLProvider(); }, + resolveProtocol: (...a) => this.resolveProtocol(...a), + }; + registerMetaObjectStateRoute.call(objectStateHost, scope); + + // [#23050 slice 13e] The published read (`GET {metaPath}/:type/:name/published`) + // moved to `meta-item-history-routes.ts`, beside the history family, and is registered + // HERE so it keeps its place in the order: after the object state-machine read, the + // last `/meta` route. Like the history family, it still registers through the per-item + // registrar this head declares, which reaches that module in `scope` as the one closure + // it is and reads the route manager when it is called, during this call. This literal + // hands that body every other member it reads (see `MetaItemPublishedRouteHost`): the + // class's methods as closures, its private static as a closure on the class, and the + // engine provider as a getter; it carries no route manager, because that body + // registers nothing on one itself. Read that module's header before moving it. + const itemPublishedHost: MetaItemPublishedRouteHost = { + metaTypeSingular: (...a) => RestServer.metaTypeSingular(...a), + resolveObjectMasker: (...a) => this.resolveObjectMasker(...a), + resolveProtocol: (...a) => this.resolveProtocol(...a), + metaItemReadGate: (...a) => this.metaItemReadGate(...a), + maskObjectDocument: (...a) => this.maskObjectDocument(...a), + get objectQLProvider() { return readObjectQLProvider(); }, + resolveMetadataService: (...a) => this.resolveMetadataService(...a), + }; + registerMetaItemPublishedRoute.call(itemPublishedHost, scope); // ── RETIRED: the compound `/:type/:section/:name` arities ────────── // diff --git a/packages/rest/src/ui-view-route-identity.measurement.test.ts b/packages/rest/src/ui-view-route-identity.measurement.test.ts index ee8d3f68b48..bb2c15fda43 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -395,7 +395,12 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () // five sites and `enforceAuth` at none, so the fourth host literal the inner // method builds forwards the first only; the five call sites moved and are // counted in both readings. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); + // [#23050 slice 13e] 17 → 18 (`meta-object-state-routes.ts`): 60 and 79 + // unchanged: neither the object state-machine read nor the published read + // (which joined `meta-item-history-routes.ts`) calls `resolveExecCtx` or + // `enforceAuth`, so neither host literal the inner method builds for them + // forwards either, and no call site moved. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(18); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); }); diff --git a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts index d8ae618b6d3..7c8be5bb545 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -1171,7 +1171,13 @@ describe('[#13214] §4 preconditions', () => { // method builds forwards the first only; the five call sites moved and are // counted in both readings. The history family never calls the ownership // guard either, so it is still 1 call site and 1 forwarder. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17); + // [#23050 slice 13e] 17 → 18 (`meta-object-state-routes.ts`): 60 and 79 + // unchanged: neither the object state-machine read nor the published read + // (which joined `meta-item-history-routes.ts`) calls `resolveExecCtx` or + // `enforceAuth`, so neither host literal the inner method builds for them + // forwards either, and no call site moved. Neither calls the ownership + // guard, so it is still 1 call site and 1 forwarder. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(18); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); // ...and the ownership guard is NOT sprayed across the file — it is a diff --git a/scripts/adr-anchors/packages__rest__src__meta-object-state-routes.ts.json b/scripts/adr-anchors/packages__rest__src__meta-object-state-routes.ts.json new file mode 100644 index 00000000000..4cf950d05f1 --- /dev/null +++ b/scripts/adr-anchors/packages__rest__src__meta-object-state-routes.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/rest/src/meta-object-state-routes.ts", + "adrs": [ + "ADR-0020" + ], + "invariant": "The `/meta` object state-machine read, moved here whole from `rest-server.ts` (#23050 slice 13e): `GET {metaPath}/object/:name/state/:field?from=:state`, registered on the route manager the inner method captures inside the umbrella's guarded registration, under no per-family switch. ADR-0020 D3.3 — this is the introspection door that decision calls for: UIs and agents read the legal next states of an object field's declared `state_machine` rule here instead of re-deriving them, so the door asks the engine's own `legalNextStates` over the object's registered schema and never keeps a transition table of its own, and it answers the three values the runtime dispatcher's twin answers (`next: null`, a declared dead end `[]`, or the legal-next list). A door that re-derives the transitions in this transport, answers a value the dispatcher twin would not, or loads the engine statically (a host serving REST without the data engine answers 501 rather than failing to load) is the drift this record forbids; the body's own comments carry the citation." +} diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 6ed374eed36..2059dab8e25 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -866,6 +866,11 @@ const MODULES = { // sibling-`code` body), so each sum is unchanged. Both only ever tick down. // Discovered by the `*-routes.ts` convention and declared in the same PR that // created the module, so the gap never exists. + // [#23050 slice 13e] The eighth and last `/meta` group, the published read + // (`{metaPath}/:type/:name/published`), joined this module on the same terms. Its + // three response bodies are all nested ADR-0112 envelopes or the served document + // (stringError 0, siblingCode 0, measured on the moved text), so both counts here + // are unchanged and `rest-server.ts`' row does not move. 'packages/rest/src/meta-item-history-routes.ts': { dialectOnly: 'moved out of `rest-server.ts` by #23050 slice 13d and held to exactly the guarantee it had there — the two dialect facts, never a write-site count', @@ -873,6 +878,25 @@ const MODULES = { stringError: 5, siblingCode: 1, }, + + // [#23050 slice 13e] The seventh `/meta` group of + // `RestServer#registerMetadataEndpointsInner`' body — the object state-machine + // read (`{metaPath}/object/:name/state/:field`) — moved out of `rest-server.ts` on + // exactly slice 1's terms above: a MOVE, no wire answer changed, so it inherits + // `dialectOnly` and the two dialect facts, and no write-site count. Both counts + // were measured on the moved file at 0 (its three response bodies are the nested + // ADR-0112 `404 NOT_FOUND` and `501 NOT_IMPLEMENTED` envelopes and the served + // answer), which is what left `rest-server.ts` (its row stays 2 / 2), so each sum + // is unchanged. Both only ever tick down. Discovered by the `*-routes.ts` + // convention and declared in the same PR that created the module, so the gap + // never exists. + 'packages/rest/src/meta-object-state-routes.ts': { + dialectOnly: + 'moved out of `rest-server.ts` by #23050 slice 13e and held to exactly the guarantee it had there — the two dialect facts, never a write-site count', + ratchet: '#9559 (option 1: convert onto the shared sendOk/sendError)', + stringError: 0, + siblingCode: 0, + }, }; /** Identifiers whose `.json()` READS a request rather than writing a response. */ diff --git a/scripts/check-undeclared-dep-imports.mjs b/scripts/check-undeclared-dep-imports.mjs index 8794dbf0a01..527a16a7197 100644 --- a/scripts/check-undeclared-dep-imports.mjs +++ b/scripts/check-undeclared-dep-imports.mjs @@ -311,13 +311,15 @@ const LEDGER = [ { pkg: '@objectstack/rest', dep: '@objectstack/objectql', - file: 'packages/rest/src/rest-server.ts', + // [#23050 slice 13e] The state-machine introspection door moved, with this dynamic + // import, from `rest-server.ts` to its own module; the row follows the occurrence. + file: 'packages/rest/src/meta-object-state-routes.ts', kind: 'optional-runtime-probe', why: 'The state-machine introspection door mirrors the dispatcher branch: the import is wrapped in ' + 'try/catch and a deployment serving REST WITHOUT the data engine answers 501 ' + 'NOT_IMPLEMENTED rather than failing to load. `@objectstack/rest` is deliberately not ' - + 'coupled to the engine — `query-multiplicity.ts` and the rest of `rest-server.ts` duck-type ' + + 'coupled to the engine — `query-multiplicity.ts` and `rest-server.ts` duck-type ' + 'the same seam for exactly that reason — so declaring it would reverse a stance the tree ' + 'states, not repair an omission.', },