@@ -26,6 +26,7 @@ import {
2626 legacyContainerEnabled ,
2727 legacyIsPrefixOf ,
2828 legacyIsRecord ,
29+ legacyPathIn ,
2930 legacySamePath ,
3031 legacyValueAtPath ,
3132} from "./push.paths.ts" ;
@@ -147,6 +148,11 @@ const REASON_VALUE_NOT_REPRESENTABLE = "the declared value could not be represen
147148const REASON_DB_SETTINGS_KEY_SHAPE =
148149 "only a top-level db.settings.<key> value can be encoded into a Postgres config write" ;
149150const REASON_INVALID_DURATION = "the declared value is not a valid duration" ;
151+ const REASON_API_DISABLED =
152+ "the Data API is disabled on the project; declare api.enabled = true to apply this" ;
153+ const REASON_COMPANION_TYPE_MISMATCH =
154+ "a required companion value has an unexpected type and could not be sent" ;
155+ const REASON_NO_ENCODER = "config push has no encoder for this property" ;
150156
151157// --- generic path/value helpers ---------------------------------------------
152158
@@ -291,6 +297,43 @@ function sortByPath<T extends { readonly path: ReadonlyArray<string> }>(
291297 return [ ...entries ] . sort ( ( a , b ) => legacyComparePaths ( a . path , b . path ) ) ;
292298}
293299
300+ /**
301+ * Every routed change an encoder receives must land in `encoded`,
302+ * `unencodable`, or `extras` — never neither. A path missing from all three
303+ * (a resource path this encoder's own switch/branch logic forgot to handle)
304+ * is pushed to `unencodable` with {@link REASON_NO_ENCODER} instead of being
305+ * silently dropped.
306+ */
307+ function finalizeEncoded < Body > (
308+ changes : ReadonlyArray < ConfigChange > ,
309+ result : LegacyPushEncoded < Body > ,
310+ ) : LegacyPushEncoded < Body > {
311+ const covered = [
312+ ...result . encoded ,
313+ ...result . unencodable . map ( ( entry ) => entry . path ) ,
314+ ...result . extras . map ( ( entry ) => entry . path ) ,
315+ ] ;
316+ const missing = changes . filter ( ( change ) => ! legacyPathIn ( change . path , covered ) ) ;
317+ if ( missing . length === 0 ) {
318+ return result ;
319+ }
320+ return {
321+ ...result ,
322+ unencodable : sortByPath ( [
323+ ...result . unencodable ,
324+ ...missing . map ( ( change ) => ( { path : change . path , reason : REASON_NO_ENCODER } ) ) ,
325+ ] ) ,
326+ } ;
327+ }
328+
329+ /** Wraps an encoder's implementation with the {@link finalizeEncoded} exhaustiveness check,
330+ * applied once here rather than at every one of an encoder's internal return points. */
331+ function withExhaustiveness < In extends LegacyPushEncoderInput , Body > (
332+ encode : ( input : In ) => LegacyPushEncoded < Body > ,
333+ ) : ( input : In ) => LegacyPushEncoded < Body > {
334+ return ( input ) => finalizeEncoded ( input . changes , encode ( input ) ) ;
335+ }
336+
294337/** Adds one leaf mapping to `body` when `path` has a routed change; `transform` returning
295338 * `undefined` marks it unencodable instead, with `reason` (defaulting to the generic
296339 * wrong-type reason) rather than always the same catch-all. */
@@ -322,60 +365,85 @@ function makeLeafAdder(
322365
323366// --- api ---------------------------------------------------------------------
324367
325- export function legacyEncodeApiBody (
326- input : LegacyPushEncoderInput ,
327- ) : LegacyPushEncoded < LegacyApiUpdateBody > {
368+ function encodeApiBody ( input : LegacyPushEncoderInput ) : LegacyPushEncoded < LegacyApiUpdateBody > {
328369 const { changes, local, remote } = input ;
329370 const encoded : Array < ReadonlyArray < string > > = [ ] ;
330371 const unencodable : Array < { path : ReadonlyArray < string > ; reason : string } > = [ ] ;
331372 const forced : Array < { path : ReadonlyArray < string > ; value : unknown } > = [ ] ;
332373
333- let dbSchema : string | undefined ;
334374 const enabledChange = findChange ( changes , [ "api" , "enabled" ] ) ;
335375 const schemasChange = findChange ( changes , [ "api" , "schemas" ] ) ;
376+ const extraSearchPathChange = findChange ( changes , [ "api" , "extra_search_path" ] ) ;
377+ const maxRowsChange = findChange ( changes , [ "api" , "max_rows" ] ) ;
378+
379+ // Whether the Data API is currently disabled and staying that way: `enabled`
380+ // itself is not a routed change, and its resolved (remote-preferred) value
381+ // is `false`. Every OTHER api.* change is then meaningless to send — there
382+ // is no live Data API for the platform to apply it to — so each routes to
383+ // `unencodable` instead of either silently riding along inside the `""`
384+ // disable sentinel (a schemas-only change would otherwise be swallowed by
385+ // it) or being sent on its own to an endpoint that ignores it while
386+ // disabled.
387+ const apiDisabled =
388+ enabledChange === undefined &&
389+ asBoolean ( resolveLeaf ( changes , [ "api" , "enabled" ] , remote , local ) . value ) === false ;
390+
391+ let dbSchema : string | undefined ;
336392 if ( enabledChange !== undefined || schemasChange !== undefined ) {
337393 const triggerPaths = [ enabledChange , schemasChange ]
338394 . filter ( ( change ) : change is ConfigChange => change !== undefined )
339395 . map ( ( change ) => change . path ) ;
340396
341- const enabledResolved = resolveLeaf ( changes , [ "api" , "enabled" ] , remote , local ) ;
342- const enabled = asBoolean ( enabledResolved . value ) ;
343- if ( enabled === false ) {
344- dbSchema = "" ;
345- encoded . push ( ...triggerPaths ) ;
346- pushForced ( forced , [ "api" , "enabled" ] , enabledResolved ) ;
397+ if ( apiDisabled ) {
398+ for ( const path of triggerPaths ) {
399+ unencodable . push ( { path, reason : REASON_API_DISABLED } ) ;
400+ }
347401 } else {
348- const schemasResolved = resolveLeaf ( changes , [ "api" , "schemas" ] , remote , local ) ;
349- const schemas = asStringArray ( schemasResolved . value ) ?? [ ] ;
350- if ( schemas . length === 0 ) {
351- for ( const path of triggerPaths ) {
352- unencodable . push ( { path, reason : REASON_API_ENABLE_NEEDS_SCHEMA } ) ;
353- }
354- } else {
355- dbSchema = schemas . join ( "," ) ;
402+ const enabledResolved = resolveLeaf ( changes , [ "api" , "enabled" ] , remote , local ) ;
403+ const enabled = asBoolean ( enabledResolved . value ) ;
404+ if ( enabled === false ) {
405+ dbSchema = "" ;
356406 encoded . push ( ...triggerPaths ) ;
357407 pushForced ( forced , [ "api" , "enabled" ] , enabledResolved ) ;
358- pushForced ( forced , [ "api" , "schemas" ] , schemasResolved ) ;
408+ } else {
409+ const schemasResolved = resolveLeaf ( changes , [ "api" , "schemas" ] , remote , local ) ;
410+ const schemas = asStringArray ( schemasResolved . value ) ?? [ ] ;
411+ if ( schemas . length === 0 ) {
412+ for ( const path of triggerPaths ) {
413+ unencodable . push ( { path, reason : REASON_API_ENABLE_NEEDS_SCHEMA } ) ;
414+ }
415+ } else {
416+ dbSchema = schemas . join ( "," ) ;
417+ encoded . push ( ...triggerPaths ) ;
418+ pushForced ( forced , [ "api" , "enabled" ] , enabledResolved ) ;
419+ pushForced ( forced , [ "api" , "schemas" ] , schemasResolved ) ;
420+ }
359421 }
360422 }
361423 }
362424
363425 let dbExtraSearchPath : string | undefined ;
364- const extraSearchPathChange = findChange ( changes , [ "api" , "extra_search_path" ] ) ;
365426 if ( extraSearchPathChange !== undefined ) {
366- dbExtraSearchPath = ( asStringArray ( extraSearchPathChange . local ) ?? [ ] ) . join ( "," ) ;
367- encoded . push ( extraSearchPathChange . path ) ;
427+ if ( apiDisabled ) {
428+ unencodable . push ( { path : extraSearchPathChange . path , reason : REASON_API_DISABLED } ) ;
429+ } else {
430+ dbExtraSearchPath = ( asStringArray ( extraSearchPathChange . local ) ?? [ ] ) . join ( "," ) ;
431+ encoded . push ( extraSearchPathChange . path ) ;
432+ }
368433 }
369434
370435 let maxRows : number | undefined ;
371- const maxRowsChange = findChange ( changes , [ "api" , "max_rows" ] ) ;
372436 if ( maxRowsChange !== undefined ) {
373- const value = asNumber ( maxRowsChange . local ) ;
374- if ( value !== undefined ) {
375- maxRows = value ;
376- encoded . push ( maxRowsChange . path ) ;
437+ if ( apiDisabled ) {
438+ unencodable . push ( { path : maxRowsChange . path , reason : REASON_API_DISABLED } ) ;
377439 } else {
378- unencodable . push ( { path : maxRowsChange . path , reason : REASON_VALUE_NOT_REPRESENTABLE } ) ;
440+ const value = asNumber ( maxRowsChange . local ) ;
441+ if ( value !== undefined ) {
442+ maxRows = value ;
443+ encoded . push ( maxRowsChange . path ) ;
444+ } else {
445+ unencodable . push ( { path : maxRowsChange . path , reason : REASON_VALUE_NOT_REPRESENTABLE } ) ;
446+ }
379447 }
380448 }
381449
@@ -396,9 +464,11 @@ export function legacyEncodeApiBody(
396464 } ;
397465}
398466
467+ export const legacyEncodeApiBody = withExhaustiveness ( encodeApiBody ) ;
468+
399469// --- db.settings ---------------------------------------------------------
400470
401- export function legacyEncodeDbSettingsBody (
471+ function encodeDbSettingsBody (
402472 input : LegacyPushEncoderInput ,
403473) : LegacyPushEncoded < LegacyDbSettingsUpdateBody > {
404474 const { changes } = input ;
@@ -435,9 +505,11 @@ export function legacyEncodeDbSettingsBody(
435505 } ;
436506}
437507
508+ export const legacyEncodeDbSettingsBody = withExhaustiveness ( encodeDbSettingsBody ) ;
509+
438510// --- db.network_restrictions -----------------------------------------------
439511
440- export function legacyEncodeNetworkRestrictionsBody (
512+ function encodeNetworkRestrictionsBody (
441513 input : LegacyPushEncoderInput ,
442514) : LegacyPushEncoded < LegacyNetworkRestrictionsUpdateBody > {
443515 const { changes, local, remote } = input ;
@@ -487,9 +559,13 @@ export function legacyEncodeNetworkRestrictionsBody(
487559 } ;
488560}
489561
562+ export const legacyEncodeNetworkRestrictionsBody = withExhaustiveness (
563+ encodeNetworkRestrictionsBody ,
564+ ) ;
565+
490566// --- db.ssl_enforcement -----------------------------------------------------
491567
492- export function legacyEncodeSslEnforcementBody (
568+ function encodeSslEnforcementBody (
493569 input : LegacyPushEncoderInput ,
494570) : LegacyPushEncoded < LegacySslEnforcementUpdateBody > {
495571 const { changes } = input ;
@@ -516,6 +592,8 @@ export function legacyEncodeSslEnforcementBody(
516592 } ;
517593}
518594
595+ export const legacyEncodeSslEnforcementBody = withExhaustiveness ( encodeSslEnforcementBody ) ;
596+
519597// --- storage -----------------------------------------------------------------
520598
521599interface LegacyStorageIcebergCatalogBody {
@@ -531,7 +609,7 @@ interface LegacyStorageVectorBucketsBody {
531609 readonly maxIndexes : number ;
532610}
533611
534- export function legacyEncodeStorageBody (
612+ function encodeStorageBody (
535613 input : LegacyStorageEncoderInput ,
536614) : LegacyPushEncoded < LegacyStorageUpdateBody > {
537615 const { changes, local, remote, config } = input ;
@@ -704,6 +782,8 @@ export function legacyEncodeStorageBody(
704782 } ;
705783}
706784
785+ export const legacyEncodeStorageBody = withExhaustiveness ( encodeStorageBody ) ;
786+
707787// --- auth ----------------------------------------------------------------
708788
709789/** `undefined` on a non-string or an unparseable duration — never `0`, so an invalid
@@ -779,6 +859,22 @@ function encodeSmtpContainer(
779859 }
780860 return undefined ;
781861 }
862+ // A companion that DID resolve but to the wrong runtime type (never
863+ // expected from a schema-typed `ProjectConfig`, but `remote` is untyped
864+ // JSON off the wire) must never be silently coerced to `""`/`"0"` below —
865+ // it makes the whole group unencodable instead.
866+ if (
867+ typeof hostR . value !== "string" ||
868+ typeof portR . value !== "number" ||
869+ typeof userR . value !== "string" ||
870+ typeof adminEmailR . value !== "string" ||
871+ typeof senderNameR . value !== "string"
872+ ) {
873+ for ( const path of unencodableTargets ( containerChanges , secret ) ) {
874+ unencodable . push ( { path, reason : REASON_COMPANION_TYPE_MISMATCH } ) ;
875+ }
876+ return undefined ;
877+ }
782878 pushForced ( forced , [ ...containerPath , "host" ] , hostR ) ;
783879 pushForced ( forced , [ ...containerPath , "port" ] , portR ) ;
784880 pushForced ( forced , [ ...containerPath , "user" ] , userR ) ;
@@ -914,6 +1010,20 @@ function encodeExternalProviderContainer(
9141010 }
9151011 return undefined ;
9161012 }
1013+ // Each companion resolved to SOME value above — but a wrong runtime type
1014+ // (client_id/url expect a string, email_optional/skip_nonce_check expect a
1015+ // boolean) must never be silently coerced to `""`/`false` below.
1016+ if (
1017+ typeof clientIdR . value !== "string" ||
1018+ ( urlR !== undefined && typeof urlR . value !== "string" ) ||
1019+ ( emailOptionalR !== undefined && typeof emailOptionalR . value !== "boolean" ) ||
1020+ ( skipNonceCheckR !== undefined && typeof skipNonceCheckR . value !== "boolean" )
1021+ ) {
1022+ for ( const path of unencodableTargets ( containerChanges , secret ) ) {
1023+ unencodable . push ( { path, reason : REASON_COMPANION_TYPE_MISMATCH } ) ;
1024+ }
1025+ return undefined ;
1026+ }
9171027 pushForced ( forced , [ ...containerPath , "client_id" ] , clientIdR ) ;
9181028 body [ `${ key } _client_id` ] = asString ( clientIdR . value ) ?? "" ;
9191029 if ( secret ?. status === "send" ) {
@@ -1009,12 +1119,21 @@ function encodeActiveSmsProviderBody(
10091119 break ;
10101120 }
10111121
1012- if ( resolutions . some ( ( { resolution } ) => resolution . source === "none" ) ) {
1122+ // Every field resolved through `field`/`optionalField` above is
1123+ // string-typed; a resolution present but of the wrong runtime type must
1124+ // never have silently fallen through `field`'s `?? ""` default, so it is
1125+ // caught here alongside the "missing entirely" case rather than shipped.
1126+ const missingResolution = resolutions . some ( ( { resolution } ) => resolution . source === "none" ) ;
1127+ const wrongTypeResolution = resolutions . some (
1128+ ( { resolution } ) => resolution . source !== "none" && typeof resolution . value !== "string" ,
1129+ ) ;
1130+ if ( missingResolution || wrongTypeResolution ) {
1131+ const reason = missingResolution ? REASON_GROUP_INCOMPLETE : REASON_COMPANION_TYPE_MISMATCH ;
10131132 for ( const change of containerChanges ) {
1014- unencodable . push ( { path : change . path , reason : REASON_GROUP_INCOMPLETE } ) ;
1133+ unencodable . push ( { path : change . path , reason } ) ;
10151134 }
10161135 for ( const path of sentSecretPaths ) {
1017- unencodable . push ( { path, reason : REASON_GROUP_INCOMPLETE } ) ;
1136+ unencodable . push ( { path, reason } ) ;
10181137 }
10191138 return undefined ;
10201139 }
@@ -1265,7 +1384,7 @@ export const LEGACY_PUSH_AUTH_LEAF_MAP: ReadonlyArray<LegacyPushAuthLeafSpec> =
12651384 } ,
12661385] ;
12671386
1268- export function legacyEncodeAuthBody (
1387+ function encodeAuthBody (
12691388 input : LegacyAuthEncoderInput ,
12701389) : LegacyPushEncoded < Readonly < Record < string , unknown > > > {
12711390 const { changes, local, remote, secrets, emailContent, remoteAuthAttributes, now } = input ;
@@ -1495,3 +1614,5 @@ export function legacyEncodeAuthBody(
14951614 secretsEncoded : [ ...secretsEncoded ] . sort ( legacyComparePaths ) ,
14961615 } ;
14971616}
1617+
1618+ export const legacyEncodeAuthBody = withExhaustiveness ( encodeAuthBody ) ;
0 commit comments