@@ -14,228 +14,45 @@ const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'
1414const SPEC_VERSION = pkg . version ;
1515
1616/**
17- * Generates an OpenAPI 3.1 specification from the ObjectStack REST API protocol schemas.
18- * This auto-generates documentation for all CRUD operations and platform endpoints.
17+ * Generates the OpenAPI 3.1 **contract half** of `GET {apiPath}/openapi.json`
18+ * from this package's REST API protocol schemas: `components.schemas`, `info`,
19+ * `securitySchemes`, the document-level `security` requirement, and a fallback
20+ * `servers` entry. That is the whole artifact, and the whole of what
21+ * `packages/spec` owns.
22+ *
23+ * ## It deliberately describes NO routes (#5588 ruling C, #5744)
24+ *
25+ * This generator used to hand-write a built-in route section —
26+ * `generateCrudPaths` / `generateMetadataPaths` / `generateDiscoveryPaths`,
27+ * 7 paths and 10 operations under a literal `basePath = '/api'`. A real boot
28+ * probed it row by row and matched **0 of 10**: every path was missing `/v1`
29+ * (CRUD also missing `/data`), `PUT {object}/{id}` named a verb the server
30+ * answers 405 to, `/api/meta/types` exists nowhere in the repo, and
31+ * `/api/.well-known/objectstack` is the runtime dispatcher's route, served at
32+ * the ROOT rather than under the API base.
33+ *
34+ * It could not be written correctly from this seat, either: `apiPath` is
35+ * per-deployment configuration (`api.apiPath ?? api.basePath + '/' + version`),
36+ * so no statically published JSON can spell the prefix right for every
37+ * deployment. A route section can only be produced by the package that MOUNTS
38+ * the routes — ADR-0076 (one route, one owner), with `packages/rest` confirmed
39+ * as this document's owner by the real boot in #5078.
40+ *
41+ * So the section has one producer, and it is not here: since #5821 the REST
42+ * server builds it at serve time from `routeManager.getAll()` — the same table
43+ * the router matches requests against — and DISCARDS whatever `paths` the
44+ * static artifact carries. #5744 (this change) removes the emission, which is
45+ * why the removal is a zero-behaviour-change cleanup rather than a regression:
46+ * the served document's route section was already rest's, byte for byte.
47+ *
48+ * `paths` is therefore ABSENT from the emitted document rather than present
49+ * and empty. OpenAPI 3.1 makes `paths` optional (a document is valid with any
50+ * one of `paths` / `components` / `webhooks`), and the two spellings say
51+ * different things: `paths: {}` asserts "this API serves nothing", which is
52+ * false, while an absent key asserts nothing about routes, which is exactly
53+ * the claim this artifact is entitled to make.
1954 */
2055
21- interface OpenApiPath {
22- [ method : string ] : {
23- summary : string ;
24- description ?: string ;
25- tags : string [ ] ;
26- operationId : string ;
27- parameters ?: Array < {
28- name : string ;
29- in : string ;
30- required : boolean ;
31- schema : Record < string , unknown > ;
32- description ?: string ;
33- } > ;
34- requestBody ?: {
35- required : boolean ;
36- content : Record < string , { schema : Record < string , unknown > } > ;
37- } ;
38- responses : Record < string , {
39- description : string ;
40- content ?: Record < string , { schema : Record < string , unknown > } > ;
41- } > ;
42- } ;
43- }
44-
45- function generateCrudPaths ( basePath : string ) : Record < string , OpenApiPath > {
46- const paths : Record < string , OpenApiPath > = { } ;
47-
48- // List records
49- paths [ `${ basePath } /{object}` ] = {
50- get : {
51- summary : 'List records' ,
52- description : 'Query records with filtering, sorting, and pagination' ,
53- tags : [ 'CRUD' ] ,
54- operationId : 'listRecords' ,
55- parameters : [
56- { name : 'object' , in : 'path' , required : true , schema : { type : 'string' } , description : 'Object name (snake_case)' } ,
57- { name : 'top' , in : 'query' , required : false , schema : { type : 'integer' , default : 25 } , description : 'Page size' } ,
58- { name : 'skip' , in : 'query' , required : false , schema : { type : 'integer' , default : 0 } , description : 'Offset' } ,
59- { name : 'sort' , in : 'query' , required : false , schema : { type : 'string' } , description : 'Sort field (prefix with - for desc)' } ,
60- { name : 'fields' , in : 'query' , required : false , schema : { type : 'string' } , description : 'Comma-separated field list' } ,
61- ] ,
62- responses : {
63- '200' : {
64- description : 'List of records' ,
65- content : { 'application/json' : { schema : { $ref : '#/components/schemas/ListRecordResponse' } } } ,
66- } ,
67- '400' : { description : 'Invalid query' , content : { 'application/json' : { schema : { $ref : '#/components/schemas/ApiError' } } } } ,
68- '401' : { description : 'Unauthorized' } ,
69- } ,
70- } ,
71- post : {
72- summary : 'Create a record' ,
73- description : 'Create a new record in the specified object' ,
74- tags : [ 'CRUD' ] ,
75- operationId : 'createRecord' ,
76- parameters : [
77- { name : 'object' , in : 'path' , required : true , schema : { type : 'string' } , description : 'Object name (snake_case)' } ,
78- ] ,
79- requestBody : {
80- required : true ,
81- content : { 'application/json' : { schema : { $ref : '#/components/schemas/CreateRequest' } } } ,
82- } ,
83- responses : {
84- '201' : {
85- description : 'Record created' ,
86- content : { 'application/json' : { schema : { $ref : '#/components/schemas/SingleRecordResponse' } } } ,
87- } ,
88- '400' : { description : 'Validation error' , content : { 'application/json' : { schema : { $ref : '#/components/schemas/ApiError' } } } } ,
89- '401' : { description : 'Unauthorized' } ,
90- } ,
91- } ,
92- } ;
93-
94- // Single record operations
95- paths [ `${ basePath } /{object}/{id}` ] = {
96- get : {
97- summary : 'Get a record' ,
98- description : 'Retrieve a single record by ID' ,
99- tags : [ 'CRUD' ] ,
100- operationId : 'getRecord' ,
101- parameters : [
102- { name : 'object' , in : 'path' , required : true , schema : { type : 'string' } , description : 'Object name' } ,
103- { name : 'id' , in : 'path' , required : true , schema : { type : 'string' } , description : 'Record ID' } ,
104- ] ,
105- responses : {
106- '200' : {
107- description : 'Record found' ,
108- content : { 'application/json' : { schema : { $ref : '#/components/schemas/SingleRecordResponse' } } } ,
109- } ,
110- '404' : { description : 'Record not found' } ,
111- '401' : { description : 'Unauthorized' } ,
112- } ,
113- } ,
114- put : {
115- summary : 'Update a record' ,
116- description : 'Update an existing record by ID' ,
117- tags : [ 'CRUD' ] ,
118- operationId : 'updateRecord' ,
119- parameters : [
120- { name : 'object' , in : 'path' , required : true , schema : { type : 'string' } , description : 'Object name' } ,
121- { name : 'id' , in : 'path' , required : true , schema : { type : 'string' } , description : 'Record ID' } ,
122- ] ,
123- requestBody : {
124- required : true ,
125- content : { 'application/json' : { schema : { $ref : '#/components/schemas/UpdateRequest' } } } ,
126- } ,
127- responses : {
128- '200' : {
129- description : 'Record updated' ,
130- content : { 'application/json' : { schema : { $ref : '#/components/schemas/SingleRecordResponse' } } } ,
131- } ,
132- '400' : { description : 'Validation error' } ,
133- '404' : { description : 'Record not found' } ,
134- '401' : { description : 'Unauthorized' } ,
135- } ,
136- } ,
137- delete : {
138- summary : 'Delete a record' ,
139- description : 'Delete a record by ID' ,
140- tags : [ 'CRUD' ] ,
141- operationId : 'deleteRecord' ,
142- parameters : [
143- { name : 'object' , in : 'path' , required : true , schema : { type : 'string' } , description : 'Object name' } ,
144- { name : 'id' , in : 'path' , required : true , schema : { type : 'string' } , description : 'Record ID' } ,
145- ] ,
146- responses : {
147- '200' : {
148- description : 'Record deleted' ,
149- content : { 'application/json' : { schema : { $ref : '#/components/schemas/DeleteResponse' } } } ,
150- } ,
151- '404' : { description : 'Record not found' } ,
152- '401' : { description : 'Unauthorized' } ,
153- } ,
154- } ,
155- } ;
156-
157- return paths ;
158- }
159-
160- function generateMetadataPaths ( basePath : string ) : Record < string , OpenApiPath > {
161- const paths : Record < string , OpenApiPath > = { } ;
162-
163- paths [ `${ basePath } /meta` ] = {
164- get : {
165- summary : 'Get platform metadata' ,
166- description : 'Returns platform-level metadata including registered types and capabilities' ,
167- tags : [ 'Metadata' ] ,
168- operationId : 'getMetadata' ,
169- responses : {
170- '200' : { description : 'Platform metadata' } ,
171- } ,
172- } ,
173- } ;
174-
175- paths [ `${ basePath } /meta/types` ] = {
176- get : {
177- summary : 'List metadata types' ,
178- description : 'Returns all registered metadata type names' ,
179- tags : [ 'Metadata' ] ,
180- operationId : 'listMetadataTypes' ,
181- responses : {
182- '200' : { description : 'List of metadata type names' } ,
183- } ,
184- } ,
185- } ;
186-
187- paths [ `${ basePath } /meta/{type}` ] = {
188- get : {
189- summary : 'List metadata by type' ,
190- description : 'Returns all metadata entries for the specified type' ,
191- tags : [ 'Metadata' ] ,
192- operationId : 'listMetadataByType' ,
193- parameters : [
194- { name : 'type' , in : 'path' , required : true , schema : { type : 'string' } , description : 'Metadata type (e.g., object, view, flow)' } ,
195- ] ,
196- responses : {
197- '200' : { description : 'List of metadata entries' } ,
198- '404' : { description : 'Unknown metadata type' } ,
199- } ,
200- } ,
201- } ;
202-
203- paths [ `${ basePath } /meta/{type}/{name}` ] = {
204- get : {
205- summary : 'Get metadata by type and name' ,
206- description : 'Returns a single metadata entry by type and name' ,
207- tags : [ 'Metadata' ] ,
208- operationId : 'getMetadataByName' ,
209- parameters : [
210- { name : 'type' , in : 'path' , required : true , schema : { type : 'string' } , description : 'Metadata type' } ,
211- { name : 'name' , in : 'path' , required : true , schema : { type : 'string' } , description : 'Metadata name' } ,
212- ] ,
213- responses : {
214- '200' : { description : 'Metadata entry' } ,
215- '404' : { description : 'Metadata not found' } ,
216- } ,
217- } ,
218- } ;
219-
220- return paths ;
221- }
222-
223- function generateDiscoveryPaths ( basePath : string ) : Record < string , OpenApiPath > {
224- return {
225- [ `${ basePath } /.well-known/objectstack` ] : {
226- get : {
227- summary : 'Platform discovery' ,
228- description : 'Returns ObjectStack platform discovery information including available services and capabilities' ,
229- tags : [ 'Discovery' ] ,
230- operationId : 'discover' ,
231- responses : {
232- '200' : { description : 'Discovery response with platform info, services, and capabilities' } ,
233- } ,
234- } ,
235- } ,
236- } ;
237- }
238-
23956function generateComponentSchemas ( ) : Record < string , Record < string , unknown > > {
24057 const schemas : Record < string , Record < string , unknown > > = { } ;
24158 const degraded : string [ ] = [ ] ;
@@ -282,8 +99,6 @@ function generateComponentSchemas(): Record<string, Record<string, unknown>> {
28299
283100// ─── Build OpenAPI Spec ──────────────────────────────────────────────
284101
285- const basePath = '/api' ;
286-
287102const openapi : Record < string , unknown > = {
288103 openapi : '3.1.0' ,
289104 info : {
@@ -299,19 +114,16 @@ const openapi: Record<string, unknown> = {
299114 url : 'https://www.apache.org/licenses/LICENSE-2.0' ,
300115 } ,
301116 } ,
117+ // Kept: the REST server prepends the live request origin and keeps this as a
118+ // trailing fallback entry, so dropping it would change the SERVED document —
119+ // and this change is meant to be invisible there.
302120 servers : [
303121 { url : 'http://localhost:3000' , description : 'Local development' } ,
304122 ] ,
305- tags : [
306- { name : 'CRUD' , description : 'Data record operations' } ,
307- { name : 'Metadata' , description : 'Platform metadata and introspection' } ,
308- { name : 'Discovery' , description : 'Service discovery and capabilities' } ,
309- ] ,
310- paths : {
311- ...generateCrudPaths ( basePath ) ,
312- ...generateMetadataPaths ( basePath ) ,
313- ...generateDiscoveryPaths ( basePath ) ,
314- } ,
123+ // No `tags`. The three that used to sit here (`CRUD` / `Metadata` /
124+ // `Discovery`) existed only to name the removed route sections; no operation
125+ // in any document carries them, and the served document's tag list is
126+ // produced with its route section, from the tags the routes register.
315127 components : {
316128 schemas : generateComponentSchemas ( ) ,
317129 securitySchemes : {
@@ -335,10 +147,27 @@ const openapi: Record<string, unknown> = {
335147// ─── Self-consistency gate (#5168) ───────────────────────────────────
336148//
337149// Runs BEFORE the write, so a document whose `$ref`s do not resolve is never
338- // emitted at all. `gen:openapi` has no staleness gate (`check:generated`
150+ // emitted at all. `gen:openapi` still has no staleness gate (`check:generated`
339151// reports it as one of the two ungated generators), so this is the only thing
340- // standing between a silently-broken collector and the published
341- // `GET /api/v1/openapi.json`. Throwing exits non-zero and fails the build.
152+ // standing between a silently-broken collector and the published artifact.
153+ // Throwing exits non-zero and fails the build.
154+ //
155+ // Since #5744 removed the hand-written route section, the emitted document
156+ // happens to carry ZERO `$ref`s — every one of the nine lived in a path
157+ // operation's request/response body. Be honest about what that means: on
158+ // today's document this call is VACUOUS, and it is retained for a reason that
159+ // is about tomorrow's, not a reason to feel covered by it now.
160+ //
161+ // The live hazard it guards is `$defs`. `z.toJSONSchema` emits reused and
162+ // recursive subschemas into a `$defs` block at the root of the schema it
163+ // RETURNS, pointing at them with root-relative `#/$defs/…` pointers. Each of
164+ // the nine is converted independently and then parked at
165+ // `components.schemas[Name]`, so the moment any contract schema becomes
166+ // recursive or shares a subschema, the pointer means `#/$defs/…` of the whole
167+ // OpenAPI document — which has no `$defs` — and every consumer that resolves it
168+ // gets nothing. None of the nine is in that shape today; `findDanglingRefs`
169+ // resolves by JSON Pointer rather than by a `#/components/schemas/` prefix
170+ // precisely so that day costs nobody a debugging session.
342171assertRefsResolve ( openapi ) ;
343172
344173// Write output
@@ -350,5 +179,8 @@ const outPath = path.join(OUT_DIR, 'openapi.json');
350179fs . writeFileSync ( outPath , JSON . stringify ( openapi , null , 2 ) ) ;
351180console . log ( `✅ Generated OpenAPI spec: ${ outPath } ` ) ;
352181console . log ( ` Version: ${ SPEC_VERSION } ` ) ;
353- console . log ( ` Paths: ${ Object . keys ( openapi . paths as object ) . length } ` ) ;
182+ // No `Paths:` line — the document has no `paths`, and a `Paths: 0` would read
183+ // as "the collector produced nothing" rather than "this artifact does not
184+ // describe routes". The route section is served by @objectstack/rest (#5588).
354185console . log ( ` Components: ${ Object . keys ( ( openapi . components as any ) . schemas ) . length } ` ) ;
186+ console . log ( ` Route sections: none — served by @objectstack/rest (#5588, ADR-0076)` ) ;
0 commit comments