@@ -7,8 +7,10 @@ import {
77 checkSortDeclaration ,
88 SORT_FIELD_UNKNOWN ,
99 SORT_FIELD_UNSORTABLE ,
10+ SORT_FIELD_UNPROVISIONED ,
1011} from './validate-sortable-fields.js' ;
1112import { indexObjectSearchTargets } from './validate-searchable-fields.js' ;
13+ import { indexUnprovisionedAnchors } from './system-fields.js' ;
1214
1315/**
1416 * The object the whole file judges against. It carries one field of each of the
@@ -489,3 +491,199 @@ describe('checkSortDeclaration — the shared core', () => {
489491 ) . toEqual ( [ ] ) ;
490492 } ) ;
491493} ) ;
494+
495+ // ── [#10474] PROVENANCE — the SORT twin of #8404's SEARCH wiring ────────────
496+ //
497+ // The census (#8999) recorded this rule as not asking the #8116 provenance
498+ // question, on the reason that an ADR-0015 external object never reaches the
499+ // union branch (skip ② was believed to catch it). That reason was measured
500+ // wrong: `declaredFieldTarget` keys on "declares no field map", never on
501+ // `external`, so the SHIPPED shape — an external object with a mapped field
502+ // map — is indexed like any other and lands in skip ③.
503+ //
504+ // ⚠️ The LOCAL twin is asserted in every case below, and it is the load-bearing
505+ // half. A wiring that warned on `created_at` for EVERY object would satisfy the
506+ // positive direction alone while flagging the single most common list-view
507+ // ordering in the platform's own objects — the ADR-0072 D1 false finding this
508+ // package's whole system-fields indirection exists to prevent. Only the
509+ // negative direction can catch that, so it is asserted every time.
510+
511+ /** The showcase's own federated object: `external` + a mapped field map. */
512+ const externalObject = {
513+ name : 'showcase_ext_customer' ,
514+ datasource : 'showcase_external' ,
515+ external : { remoteName : 'customers' } ,
516+ fields : {
517+ name : { type : 'text' , label : 'Name' } ,
518+ email : { type : 'text' , label : 'Email' } ,
519+ region : { type : 'text' , label : 'Region' } ,
520+ } ,
521+ } ;
522+
523+ /** Its local twin — identical in every way EXCEPT `external`. */
524+ const localTwin = {
525+ name : 'showcase_customer' ,
526+ fields : {
527+ name : { type : 'text' , label : 'Name' } ,
528+ email : { type : 'text' , label : 'Email' } ,
529+ region : { type : 'text' , label : 'Region' } ,
530+ } ,
531+ } ;
532+
533+ /** Both objects, each with a list view ordering by the same injected anchor. */
534+ const twinStack = ( sort : unknown ) => ( {
535+ objects : [
536+ { ...externalObject , listViews : { recent : { type : 'grid' , sort } } } ,
537+ { ...localTwin , listViews : { recent : { type : 'grid' , sort } } } ,
538+ ] ,
539+ } ) ;
540+
541+ describe ( 'validateSortableFields — the provenance verdict (#10474)' , ( ) => {
542+ it ( 'warns on a list-view sort ordering by an unprovisioned injected anchor' , ( ) => {
543+ const findings = validateSortableFields ( twinStack ( [ { field : 'created_at' , order : 'desc' } ] ) ) ;
544+
545+ expect ( findings ) . toHaveLength ( 1 ) ;
546+ const f = findings [ 0 ] ;
547+ expect ( f . rule ) . toBe ( SORT_FIELD_UNPROVISIONED ) ;
548+ // WARNING, not error: no runtime door refuses this, and the remote schema
549+ // is invisible to this pass. The runtime publish gate sorts on severity —
550+ // `error` would turn an unprovable suspicion into a refused write.
551+ expect ( f . severity ) . toBe ( 'warning' ) ;
552+ expect ( f . where ) . toBe ( 'object "showcase_ext_customer" › listViews.recent' ) ;
553+ expect ( f . path ) . toBe ( 'objects[0].listViews.recent.sort[0]' ) ;
554+ expect ( f . message ) . toContain ( 'created_at' ) ;
555+ // The CAUSE clause is the package-shared sentence, not a re-typed one:
556+ // a rule that re-words it drifts from the runtime guards whose verdict it
557+ // reports (`unprovisionedAnchorCause`).
558+ expect ( f . message ) . toContain ( 'injected system column with NO storage behind it' ) ;
559+ expect ( f . message ) . toContain ( 'ADR-0015' ) ;
560+ // The SORT-axis consequence, which is this rule's own half of the sentence.
561+ expect ( f . message ) . toContain ( 'ORDER BY' ) ;
562+ expect ( f . hint ) . toContain ( 'columnMap' ) ;
563+ } ) ;
564+
565+ it ( 'THE NEGATIVE DIRECTION: says nothing about the identical sort on the LOCAL twin' , ( ) => {
566+ // `objects[1]` is the local twin and carries the identical declaration.
567+ // The single finding above is proof enough only alongside this.
568+ const findings = validateSortableFields ( twinStack ( [ { field : 'created_at' , order : 'desc' } ] ) ) ;
569+ expect ( findings . map ( ( x ) => x . path ) ) . not . toContain ( 'objects[1].listViews.recent.sort[0]' ) ;
570+ expect (
571+ validateSortableFields ( {
572+ objects : [ { ...localTwin , listViews : { recent : { type : 'grid' , sort : 'created_at desc' } } } ] ,
573+ } ) ,
574+ ) . toEqual ( [ ] ) ;
575+ } ) ;
576+
577+ it ( 'covers every anchor the injection registers, not just the audit family' , ( ) => {
578+ // `owner_id` is the one no managed DDL ever creates either, so it is the
579+ // clearest case; asserting the set keeps a narrowing of the derivation
580+ // visible here rather than only in the spec's own test.
581+ for ( const anchor of [ 'created_at' , 'created_by' , 'updated_at' , 'owner_id' , 'organization_id' ] ) {
582+ const findings = validateSortableFields ( twinStack ( [ { field : anchor , order : 'asc' } ] ) ) ;
583+ expect ( findings . map ( ( x ) => x . rule ) , anchor ) . toEqual ( [ SORT_FIELD_UNPROVISIONED ] ) ;
584+ expect ( findings [ 0 ] . message , anchor ) . toContain ( anchor ) ;
585+ }
586+ } ) ;
587+
588+ it ( 'reads the legacy string sort form too, not only the structured array' , ( ) => {
589+ const findings = validateSortableFields ( twinStack ( 'created_at desc' ) ) ;
590+ expect ( findings ) . toHaveLength ( 1 ) ;
591+ expect ( findings [ 0 ] . rule ) . toBe ( SORT_FIELD_UNPROVISIONED ) ;
592+ // The string form has no index suffix.
593+ expect ( findings [ 0 ] . path ) . toBe ( 'objects[0].listViews.recent.sort' ) ;
594+ } ) ;
595+
596+ it ( "SECURITY DIRECTION: an author-DECLARED anchor on the federated object is silent" , ( ) => {
597+ // #7859's recorded reasoning — a federated object may expose a REAL remote
598+ // `created_at`, which the author vouches for through the binding's
599+ // columnMap. `unprovisionedInjectedColumnsFor` excludes it, so declaring
600+ // the column is the first remedy the shared hint prescribes AND the thing
601+ // that silences the finding.
602+ const declared = {
603+ ...externalObject ,
604+ fields : { ...externalObject . fields , created_at : { type : 'datetime' , label : 'Remote Created' } } ,
605+ listViews : { recent : { type : 'grid' , sort : [ { field : 'created_at' , order : 'desc' } ] } } ,
606+ } ;
607+ expect ( validateSortableFields ( { objects : [ declared ] } ) ) . toEqual ( [ ] ) ;
608+ } ) ;
609+
610+ it ( 'respects the injection opt-outs — `systemFields: false` leaves no anchor to warn about' , ( ) => {
611+ const optedOut = {
612+ ...externalObject ,
613+ systemFields : false ,
614+ listViews : { recent : { type : 'grid' , sort : [ { field : 'created_at' , order : 'desc' } ] } } ,
615+ } ;
616+ expect ( validateSortableFields ( { objects : [ optedOut ] } ) ) . toEqual ( [ ] ) ;
617+ } ) ;
618+
619+ it ( 'DOTTED heads are NOT asked — the ingress gate already refuses them loudly' , ( ) => {
620+ // The one place this axis departs from the SEARCH twin, deliberately: a
621+ // dotted SORT name is a `400 INVALID_SORT` on every fetch, so the silent
622+ // degradation this finding reports cannot happen there, and answering
623+ // would give the SORT axis its own dotted verdict (the posture the module
624+ // note records as shared with FILTER/PROJECTION).
625+ const findings = validateSortableFields ( twinStack ( [ { field : 'created_at.year' , order : 'asc' } ] ) ) ;
626+ expect ( findings ) . toEqual ( [ ] ) ;
627+ } ) ;
628+
629+ it ( 'is additive: the existence verdict on a real typo still fires beside it' , ( ) => {
630+ const findings = validateSortableFields (
631+ twinStack ( [ { field : 'created_at' , order : 'desc' } , { field : 'nope' , order : 'asc' } ] ) ,
632+ ) ;
633+ const external = findings . filter ( ( x ) => x . path . startsWith ( 'objects[0]' ) ) ;
634+ expect ( external . map ( ( x ) => x . rule ) ) . toEqual ( [ SORT_FIELD_UNPROVISIONED , SORT_FIELD_UNKNOWN ] ) ;
635+ // …and the local twin still gets the typo, and ONLY the typo.
636+ const local = findings . filter ( ( x ) => x . path . startsWith ( 'objects[1]' ) ) ;
637+ expect ( local . map ( ( x ) => x . rule ) ) . toEqual ( [ SORT_FIELD_UNKNOWN ] ) ;
638+ } ) ;
639+
640+ it ( 'reaches the `defineView` aggregate and standalone list-view rungs too' , ( ) => {
641+ const base = { objects : [ externalObject ] } ;
642+ const sort = [ { field : 'created_at' , order : 'desc' } ] ;
643+ const rungs : Array < [ string , unknown [ ] ] > = [
644+ [ 'aggregate list' , [ { name : 'v' , objectName : 'showcase_ext_customer' , list : { sort } } ] ] ,
645+ [ 'aggregate listViews' , [ { name : 'v' , objectName : 'showcase_ext_customer' , listViews : { a : { sort } } } ] ] ,
646+ [ 'flattened overlay' , [ { name : 'v' , object : 'showcase_ext_customer' , viewKind : 'list' , sort } ] ] ,
647+ [ 'ViewItem record' , [ { name : 'v' , object : 'showcase_ext_customer' , viewKind : 'list' , config : { sort } } ] ] ,
648+ ] ;
649+ for ( const [ label , views ] of rungs ) {
650+ const findings = validateSortableFields ( { ...base , views } ) ;
651+ expect ( findings . map ( ( x ) => x . rule ) , label ) . toEqual ( [ SORT_FIELD_UNPROVISIONED ] ) ;
652+ }
653+ } ) ;
654+ } ) ;
655+
656+ describe ( 'checkSortDeclaration — the provenance parameter is OPTIONAL (#10474)' , ( ) => {
657+ const stack = { objects : [ externalObject ] } ;
658+
659+ it ( 'asks nothing when the caller does not build the index (pre-#10474 behaviour)' , ( ) => {
660+ // The exported core is public surface; an out-of-repo caller that never
661+ // built the index must keep the answers it had.
662+ expect (
663+ checkSortDeclaration (
664+ [ { field : 'created_at' , order : 'desc' } ] ,
665+ 'showcase_ext_customer' ,
666+ indexObjectSearchTargets ( stack ) ,
667+ 'page "customers"' ,
668+ 'pages[0].sort' ,
669+ 'page sort' ,
670+ ) ,
671+ ) . toEqual ( [ ] ) ;
672+ } ) ;
673+
674+ it ( 'asks once the caller passes it' , ( ) => {
675+ const findings = checkSortDeclaration (
676+ [ { field : 'created_at' , order : 'desc' } ] ,
677+ 'showcase_ext_customer' ,
678+ indexObjectSearchTargets ( stack ) ,
679+ 'page "customers"' ,
680+ 'pages[0].sort' ,
681+ 'page sort' ,
682+ indexUnprovisionedAnchors ( stack ) ,
683+ ) ;
684+ expect ( findings ) . toHaveLength ( 1 ) ;
685+ expect ( findings [ 0 ] . rule ) . toBe ( SORT_FIELD_UNPROVISIONED ) ;
686+ expect ( findings [ 0 ] . where ) . toBe ( 'page "customers"' ) ;
687+ expect ( findings [ 0 ] . message ) . toContain ( 'page sort' ) ;
688+ } ) ;
689+ } ) ;
0 commit comments